Merge release/2.0.0 into main — v2.0.0 Knowledge-to-Action Foundation

This commit is contained in:
Yan 2026-05-20 23:35:07 +08:00
commit 94d30fe962
75 changed files with 5063 additions and 1180 deletions

BIN
.DS_Store vendored

Binary file not shown.

82
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,82 @@
name: Bug report
description: Report a problem with IStart-Note-AI
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a report. Please fill in as much as you can.
For security issues, follow [SECURITY.md](https://github.com/yan-istart/IStart-Note-AI-Plugin/blob/main/SECURITY.md) instead.
- type: input
id: plugin-version
attributes:
label: Plugin version
placeholder: "e.g. 1.8.3"
validations:
required: true
- type: input
id: obsidian-version
attributes:
label: Obsidian version
placeholder: "e.g. 1.7.2"
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- macOS desktop
- Windows desktop
- Linux desktop
- iOS mobile
- Android mobile
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear and concise description of the bug.
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: How can someone else reproduce the bug?
placeholder: |
1. Open vault ...
2. Right-click ...
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: false
- type: textarea
id: console
attributes:
label: Console output
description: Output from the developer console (Ctrl/Cmd+Shift+I → Console). Redact API keys.
render: shell
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional context
description: Settings, screenshots, related notes (with sensitive content removed).
validations:
required: false

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security report
url: https://github.com/yan-istart/IStart-Note-AI-Plugin/security/advisories/new
about: Please report security issues privately, not via public issues.
- name: Question or discussion
url: https://github.com/yan-istart/IStart-Note-AI-Plugin/discussions
about: Ask a question or discuss an idea before filing a feature request.

View file

@ -0,0 +1,33 @@
name: Feature request
description: Suggest an idea for IStart-Note-AI
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: What problem are you trying to solve?
description: Describe the workflow you can't accomplish today, not the solution.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: What would the feature look like? Mockups and examples are welcome.
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional context
validations:
required: false

31
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,31 @@
<!-- Thanks for the PR. Please fill in the sections that apply. -->
## Summary
<!-- What does this PR change, in one or two sentences? -->
## Why
<!-- Link an issue if there is one, or describe the motivation. -->
## How was it tested?
<!-- Manual test steps, or commands run. -->
- [ ] `npm run typecheck` passes
- [ ] `npm run build` passes
- [ ] Tested manually in Obsidian
## User-facing impact
<!--
Mark "none" for refactors / internal changes.
For changes that affect vault contents (frontmatter, paths, sync layout), describe migration impact and add a note to CHANGELOG.md.
-->
## Checklist
- [ ] PR is focused on a single change
- [ ] Updated `CHANGELOG.md` under "Unreleased" if user-visible
- [ ] Updated `README.md` / `README.zh-CN.md` if behavior or setup changed
- [ ] No new direct `requestUrl` calls to LLM endpoints (use `core/llm` instead)

25
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,25 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
groups:
typescript:
patterns:
- "typescript"
- "@typescript-eslint/*"
esbuild:
patterns:
- "esbuild"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
labels:
- "dependencies"
- "ci"

35
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: CI
on:
push:
branches: [main, master, "release/**"]
pull_request:
branches: [main, master]
jobs:
build:
name: Type-check and build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Type-check
run: npm run typecheck
- name: Build
run: npm run build
- name: Verify build artifacts
run: |
test -f dist/main.js
test -f dist/manifest.json
test -f dist/styles.css

View file

@ -22,7 +22,7 @@ jobs:
- name: Build plugin
run: |
npm install
npm ci
npm run build
- name: Generate artifact attestations

13
.gitignore vendored
View file

@ -1,3 +1,16 @@
# Build output (release assets are built fresh from CI/release scripts)
node_modules/
dist/
*.js.map
# OS junk
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
# Local env / secrets
.env
.env.*

42
CHANGELOG.md Normal file
View file

@ -0,0 +1,42 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `src/core/llm/`: unified LLM client (`LLMClient`) and JSON extractor (`extractJson`, `parseJsonSafe`, `parseJsonStrict`) shared by every AI feature.
- `src/core/schema.ts`: `SCHEMA_VERSION` constant (starts at `1`) and `todayIso()` helper. All plugin-managed frontmatter now includes `schema_version: 1`.
- Bilingual documentation: `README.md`, `README.zh-CN.md`, `PRIVACY.md`, `PRIVACY.zh-CN.md`.
- `CONTRIBUTING.md`, `SECURITY.md`, `CHANGELOG.md`, GitHub issue and pull request templates.
- `.github/workflows/ci.yml`: type-check and build on push/PR.
- `.github/dependabot.yml`: weekly npm and monthly GitHub Actions updates.
- Conflict-safe file creation in `VaultWriter` and the AI assistant's concept-page flow (auto-suffixes `-2`, `-3`, ... when paths collide).
- **Concept page completion** actions wired into the command panel:
- "补全当前概念页" — opens depth select → AI completion → preview → write.
- "扫描并补全空概念页" — batch scan → select → sequential completion.
- **Knowledge Q&A with question graph** action:
- "知识提问" — ask → auto-classify (new/refinement/expansion) → user confirm → generate Q&A → attach classification frontmatter → update question index → append recommended follow-ups → rebuild Mermaid evolution graph.
- **Vault-aware Q&A** action ("知识库问答"):
- Searches the in-memory vault index for related notes, builds a context window from up to 8 relevant entries, sends to the LLM with instructions to cite `[[sources]]`, and renders the answer with a "依据来源" section.
- `src/core/knowledge/KnowledgeIndexService.ts` — in-memory vault index built from metadataCache. Rebuilt on load, incrementally updated on file change/delete/rename. Three-layer scoring: exact match → structural (links/backlinks/domain) → keyword substring. No embedding, no external DB.
- `src/core/execution/` — ExecutionPlan infrastructure:
- `types.ts`: `VaultOperation` union (create-file, modify-file, append-section, replace-selection, move-file, create-link, update-frontmatter), `ExecutionPlan`, `ExecutionRecord`.
- `PlanBuilder.ts`: fluent builder with automatic risk assessment and preview markdown generation.
- `PlanExecutor.ts`: applies plans to the vault and persists execution logs under `Knowledge/_Executions/`.
### Changed
- All nine LLM call sites (`AIAssistant`, `DeepSeekClient`, `QuestionClassifier`, `ConceptCompleter`, `ContextQAClient`, `ReadingPlanner`, `SectionAppender`, `DiagramGenerator`, `SmartCompleter`) now go through `LLMClient` and `extractJson`.
- `tsconfig.json`: enabled full strict mode (`strict: true`) and scoped `include` to `src/**/*.ts`.
- `package.json`: pinned `obsidian` to `^1.7.2` (was `latest`), added `homepage`, `repository`, `bugs`, `keywords`, and `author` fields, added `typecheck`, `test`, and `ci` scripts.
- Release workflow uses `npm ci` instead of `npm install` for reproducible builds.
- Action definitions now use appropriate groups (`concept`, `reading`, `sync`, `document`) instead of all being `general`.
### Removed
- Stale committed `main.js` build artifact at the repository root. The runtime bundle is now produced into `dist/` only and shipped exclusively via GitHub Releases.
## Older versions
Historical entries prior to this changelog were not maintained. See [the versions.json](./versions.json) for the version-to-minimum-Obsidian map and the [GitHub Releases page](https://github.com/yan-istart/IStart-Note-AI-Plugin/releases) for prior release notes.

107
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,107 @@
# Contributing to IStart-Note-AI
Thanks for your interest in improving IStart-Note-AI. This document covers how to set up the project, the conventions we follow, and how to send a change.
## Setup
```bash
git clone https://github.com/yan-istart/IStart-Note-AI-Plugin.git
cd IStart-Note-AI-Plugin
npm ci
```
Always use `npm ci` rather than `npm install` so the lockfile is respected and builds are reproducible.
## Common commands
| Command | What it does |
| --- | --- |
| `npm run dev` | Watch mode build to `dist/` |
| `npm run typecheck` | Run `tsc --noEmit` against `tsconfig.json` (strict mode) |
| `npm run build` | Type-check, then production build to `dist/` |
| `npm run ci` | Full type-check + build (mirrors GitHub Actions) |
| `npm run release` | Interactive release (build, tag, GitHub release) — maintainers only |
| `npm run submit` | Helper for submitting to community plugin store |
To run the plugin locally, symlink or copy `dist/` into your test vault's `.obsidian/plugins/istart-note-ai/` folder, then enable the plugin in Obsidian.
## Project layout
```
src/
core/ # cross-feature infrastructure (LLM client, future vault index, ...)
ai/ # AI feature modules (assistant, classifier, planner, ...)
features/ # UI and per-feature managers
vault/ # vault read/write helpers
settings/
actions/ # unified action registry + command panel
main.ts
```
### Working with the LLM
All AI features must use `src/core/llm/LLMClient.ts` instead of calling `requestUrl` directly:
```typescript
import { LLMClient, parseJsonSafe } from "../core/llm";
const llm = new LLMClient(settings);
const raw = await llm.chat({ userPrompt: prompt, temperature: 0.5 });
const parsed = parseJsonSafe<MyShape>(raw, defaultShape);
```
This keeps error handling, header construction, and provider-swapping in one place.
### Vault writes
Use `VaultWriter` for the standard QA / context-QA / concept patterns. When you need a unique file path, use `VaultWriter`'s conflict-safe helper rather than calling `app.vault.create` directly.
When introducing new file types, plan for migration: include `schema_version` in the frontmatter and document the schema in the PR.
## Coding conventions
- TypeScript strict mode is on. New code must pass `npm run typecheck` without `any` (use proper types or `unknown` + narrowing).
- Avoid `console.log` in shipped code. Use `Notice` for user-visible messages.
- Keep modules small and single-purpose. Cross-feature utilities live in `src/core/`.
- Mirror existing language usage. UI strings are Chinese; code comments are mixed Chinese/English. Pick whichever fits the surrounding code.
- Prefer pure functions in `core/` and side-effect-bearing classes (modal, manager, ...) in `features/`.
## Testing
A formal test framework is not wired in yet. The `npm test` script is a placeholder so CI passes. When you add tests, use [vitest](https://vitest.dev) and put them next to the unit under test as `*.test.ts`. We will switch the placeholder script over once the first tests land.
If your change is non-trivial, please describe how you manually verified it in the PR description.
## Pull requests
1. Fork the repo and create a topic branch (`feat/...`, `fix/...`, `docs/...`).
2. Run `npm run ci` locally — PRs failing CI are not reviewed.
3. Keep PRs focused. Unrelated cleanups belong in separate PRs.
4. Update [CHANGELOG.md](./CHANGELOG.md) under the "Unreleased" section.
5. If your change affects user data — frontmatter, vault paths, sync layout — mention migration impact in the PR description.
## Releasing (maintainers)
Releases are tag-driven. Pushing a tag triggers `.github/workflows/release.yml`, which builds `dist/` and creates a draft release.
Recommended flow:
```bash
npm run release # interactive: bump version, build, tag, push, draft release
```
The release workflow uses `npm ci`, so any change that requires a new lockfile must be committed first.
## Reporting bugs
Open an issue with:
- Obsidian version, plugin version, OS.
- Reproduction steps. A short snippet of the affected note (with secrets removed) helps a lot.
- Anything visible in the developer console (`Ctrl/Cmd+Shift+I` → Console).
For security issues, follow [SECURITY.md](./SECURITY.md) instead.
## Code of conduct
Be respectful. Stay on topic. Disagreements about technical direction are fine; personal attacks aren't.

87
PRIVACY.md Normal file
View file

@ -0,0 +1,87 @@
# Privacy Policy
_Last updated: 2026-05-20. [简体中文版本 →](./PRIVACY.zh-CN.md)_
This document describes what data IStart-Note-AI handles, where it goes, and how to control it. The plugin runs entirely inside your Obsidian client — there are no plugin-operated servers, no analytics, and no telemetry.
## Summary
- The plugin sends prompts to **the AI provider you configure** (default: DeepSeek). Nothing else.
- The plugin uploads files to **your own Baidu Pan storage** when you enable Baidu sync. Nothing else.
- API keys, Baidu OAuth tokens, and other secrets are stored locally in your vault's plugin data file (`<your-vault>/.obsidian/plugins/istart-note-ai/data.json`).
- The plugin does not phone home, send analytics, or contact any third party other than the providers above.
## What is sent to the AI provider
When you trigger an AI feature, the plugin makes an HTTPS request to the chat completions endpoint configured under **Settings → IStart-Note-AI → Base URL** (default: `https://api.deepseek.com/v1/chat/completions`).
The request body may include:
| Source | Data | When |
| --- | --- | --- |
| Selection | The currently selected text | Whenever you trigger the AI assistant with a non-empty selection |
| Active file | The active document content (truncated, typically up to ~2,000 characters) | When the assistant needs context (most actions) |
| Active file metadata | File name, frontmatter `type` field | Most actions |
| Cursor context | Up to ~500 characters before the cursor | Continue / fill-empty-section actions |
| Concept name list | The list of file basenames under your Concepts folder | All actions, used to drive auto-linking |
| Question history | Up to the last 20 question titles in your Q&A folder | Question classification |
| Reading notes | The notes for a specific chapter (truncated to ~3,000 characters) | Reading project: chapter summary, Feynman test |
| Your instruction | The natural-language prompt you type into the assistant | Always |
The plugin never reads files outside the configured paths unless you explicitly point it at them. The plugin never sends your API key to anything other than the provider's endpoint.
You are subject to the privacy policy of whichever provider you configure. Review:
- DeepSeek: <https://platform.deepseek.com>
- For other providers, consult their own documentation.
## What is sent to Baidu Cloud
Baidu Cloud sync is **disabled by default**. When you enable it under **Settings → IStart-Note-AI → Baidu Cloud Sync**, you supply:
- An **App ID** and **App Secret** from the [Baidu Pan Open Platform](https://pan.baidu.com/union).
- An OAuth authorization code that the plugin exchanges for an `accessToken` and `refreshToken`.
After enabling sync, the plugin can upload to your own Baidu Pan account at the path you configure (default: `/apps/istart-note-ai`). The data uploaded depends on your settings:
- **Notes**: the markdown files in the folders you choose to sync.
- **Plugin config (optional)**: a small JSON file containing non-secret plugin settings.
- **Plugin itself (optional)**: the compiled plugin files in `.obsidian/plugins/istart-note-ai/`.
- **Obsidian config (optional)**: a curated set of files from `.obsidian/` (toolbar, hotkeys, appearance, community-plugins).
Files are uploaded over HTTPS using the Baidu Pan REST API. The plugin **does not encrypt files end-to-end**; treat your Baidu Pan account security as the boundary.
You can disable sync at any time. Removing the local plugin data file (or running Settings → Community plugins → Reset) clears stored Baidu tokens.
## Where credentials are stored
All settings, including the DeepSeek API key, the Baidu App Secret, and the Baidu access/refresh tokens, are stored in:
```
<your-vault>/.obsidian/plugins/istart-note-ai/data.json
```
Anything inside your vault — including this file — is local to your machine unless you sync it elsewhere yourself. The plugin never transmits this file.
If you sync your vault via iCloud, Obsidian Sync, Git, or another mechanism, **you are responsible for whether `data.json` is included in that sync**. The Obsidian default is to include it. To exclude it, configure an exclusion rule in your sync tool.
## What is **not** collected
- No telemetry, usage analytics, or crash reporting.
- No outbound requests to addresses other than the configured AI provider and Baidu Pan.
- No background uploads when sync is disabled.
- No personal information beyond what your prompts and notes already contain.
## Mobile
The plugin runs on Obsidian Mobile and follows the same rules. AI requests go to the same endpoint over the device's network. Baidu sync on mobile uses the same OAuth credentials.
## Data retention and deletion
- **Local data**: delete `<your-vault>/.obsidian/plugins/istart-note-ai/data.json` to remove all locally stored settings and tokens.
- **Baidu Pan**: open the Baidu Pan web UI or app and delete the folder you configured (default: `/apps/istart-note-ai`) to remove uploaded notes.
- **AI provider**: refer to the provider's data retention policy. The plugin does not retain anything itself.
## Reporting concerns
For security or privacy issues, follow [SECURITY.md](./SECURITY.md). For other concerns, open an issue on GitHub.

87
PRIVACY.zh-CN.md Normal file
View file

@ -0,0 +1,87 @@
# 隐私政策
_最近更新2026-05-20。[English →](./PRIVACY.md)_
本文档描述 IStart-Note-AI 处理哪些数据、数据流向哪里、以及如何控制。插件完全运行在你的 Obsidian 客户端内:没有插件方服务器,不收集分析数据,也不上报遥测。
## 概览
- 插件只会把请求发到**你配置的 AI 服务**(默认 DeepSeek
- 启用百度云同步时,插件只会把文件上传到**你自己的百度网盘**。
- API Key、百度 OAuth Token 等凭证保存在 Vault 的插件数据文件里:`<你的 Vault>/.obsidian/plugins/istart-note-ai/data.json`。
- 不向其他第三方发送任何数据。
## 发送到 AI 服务的数据
触发 AI 功能时,插件会向**设置 → IStart-Note-AI → Base URL**(默认 `https://api.deepseek.com/v1/chat/completions`)发起 HTTPS 请求。
请求体可能包含:
| 来源 | 数据 | 触发时机 |
| --- | --- | --- |
| 选中文字 | 当前选中的文本 | 选中后调用 AI 助手 |
| 当前文件 | 当前文档内容(通常截断到约 2,000 字符) | 大多数动作 |
| 文件元数据 | 文件名、frontmatter `type` 字段 | 大多数动作 |
| 光标上下文 | 光标前最多约 500 字符 | 续写 / 空章节补全 |
| 概念名列表 | 概念目录下的所有文件名(用于自动双链) | 所有动作 |
| 问题历史 | 问答目录下最近 20 条问题标题 | 问题分类 |
| 阅读笔记 | 单个章节的笔记(截断到约 3,000 字符) | 章节总结、费曼测试 |
| 你的指令 | 你在助手中输入的自然语言指令 | 始终发送 |
插件不会读取你显式配置之外的目录,也不会把 API Key 发往配置的 endpoint 之外的任何地方。
具体数据由所配置服务的隐私政策约束。请自行查阅:
- DeepSeek<https://platform.deepseek.com>
- 其他兼容服务请查阅对应官方文档。
## 上传到百度网盘的数据
百度云同步**默认关闭**。在**设置 → IStart-Note-AI → 百度云同步**中启用时,你需要提供:
- 在 [百度网盘开放平台](https://pan.baidu.com/union) 注册的 **App ID****App Secret**
- 一次性 OAuth 授权码,插件会换取 `accessToken``refreshToken`
启用后,插件会把以下内容上传到你自己百度网盘的指定路径(默认 `/apps/istart-note-ai`
- **笔记**:你选择同步的目录下的 markdown 文件。
- **插件配置(可选)**:不含凭证的小型 JSON 文件。
- **插件本身(可选)**`.obsidian/plugins/istart-note-ai/` 下的编译产物。
- **Obsidian 配置(可选)**`.obsidian/` 下的部分文件(工具栏、快捷键、外观、社区插件)。
文件通过百度网盘 REST API 走 HTTPS 上传。**插件不做端到端加密**,安全边界等同你百度网盘账号的安全。
你可以随时关闭同步。删除插件 data.json 或在「设置 → 第三方插件 → 重置」可清除本地存储的百度凭证。
## 凭证存放位置
所有设置(含 DeepSeek API Key、百度 App Secret、百度 access/refresh token都存在
```
<你的 Vault>/.obsidian/plugins/istart-note-ai/data.json
```
Vault 内的内容默认是本地的,除非你主动同步到别处。插件本身不会传输这个文件。
如果你通过 iCloud、Obsidian Sync、Git 等方式同步 Vault**是否包含 `data.json` 由你的同步工具决定**。Obsidian 默认是包含的。如需排除,请在同步工具中添加忽略规则。
## 不会收集的内容
- 不收集遥测 / 使用分析 / 崩溃日志。
- 除配置的 AI 服务和百度网盘外,不向任何外部地址发请求。
- 同步关闭时不会有后台上传。
- 不收集 prompts 与笔记之外的个人信息。
## 移动端
插件支持 Obsidian Mobile规则与桌面端一致。AI 请求走同一 endpoint百度同步使用同一套 OAuth 凭证。
## 数据保留与删除
- **本地数据**:删除 `<你的 Vault>/.obsidian/plugins/istart-note-ai/data.json`
- **百度网盘**:在百度网盘网页 / 客户端上删除同步目录(默认 `/apps/istart-note-ai`)。
- **AI 服务**:请参阅对应服务的数据保留策略;插件本身不留任何记录。
## 反馈渠道
安全或隐私问题请按 [SECURITY.md](./SECURITY.md) 流程提交。其他问题请在 GitHub 上提 issue。

220
README.md Normal file
View file

@ -0,0 +1,220 @@
# IStart-Note-AI
<p align="center">
<strong>Turn your Obsidian vault into a knowledge-to-action system.</strong>
</p>
<p align="center">
<a href="./README.zh-CN.md">简体中文</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#privacy">Privacy</a> ·
<a href="#roadmap">Roadmap</a>
</p>
<p align="center">
<img alt="Version" src="https://img.shields.io/github/v/release/yan-istart/IStart-Note-AI-Plugin?include_prereleases">
<img alt="License" src="https://img.shields.io/github/license/yan-istart/IStart-Note-AI-Plugin">
<img alt="CI" src="https://img.shields.io/github/actions/workflow/status/yan-istart/IStart-Note-AI-Plugin/ci.yml?branch=main">
<img alt="Obsidian" src="https://img.shields.io/badge/Obsidian-1.7.2%2B-7C3AED">
</p>
---
IStart-Note-AI is an Obsidian plugin built around three modules — **Knowledge**, **Execution**, and **Auxiliary** — that help you turn scattered notes into a searchable, interlinked, and actionable personal knowledge system.
One unified AI entry point connects all three: ask questions, generate structured notes, build execution plans, and keep everything synced — all through natural language.
> [!warning] Beta
> v2.0.0 introduces significant architectural changes. The frontmatter schema and scheduled-task model are not yet stable. Back up your vault before upgrading.
---
## Core Modules
### 1. Knowledge
Build and maintain a structured knowledge base.
- **Ask questions** and generate Q&A notes with automatic concept extraction.
- **Classify questions** into new / refinement / expansion and maintain a question evolution graph.
- **Create and complete concept pages** with definitions, examples, relations, and domain MOC indexes.
- **Build reading projects** — generate a book skeleton, chapter pre-reading questions, summaries, and Feynman tests.
- **Search your vault** and get answers with `[[source]]` references (metadata-based index, no embeddings).
- **Detect knowledge debt** — empty concepts, orphan questions, unfinished readings, stale drafts.
### 2. Execution
Turn knowledge into reviewable actions.
- **Execution plan data model** — PlanBuilder + PlanExecutor + PlanDraftStore for multi-op vault changes.
- **Execution logs** recorded under `Knowledge/_Executions/` after each plan is applied.
- **Plan drafts** stored under `Knowledge/_ExecutionPlans/` for `create-plan-only` tasks (user reviews before applying).
- **Scheduler foundation** — ScheduledTask types and runner exist; runtime is disabled by default in v2.0 (enabled in v2.1).
- **Safety by default**`create-plan-only` never auto-executes; `auto-execute-low-risk` only applies plans with `riskLevel: "low"`.
- Most AI write flows (assistant, beautify, concept completion) still use direct editor writes; migration to plan-first is in progress.
- Future: diff preview, rollback, batch-op caps, task-plugin integrations.
### 3. Auxiliary
Keep the system usable across devices and providers.
- **OpenAI-compatible LLM provider** — DeepSeek default; change Base URL for others.
- **Configurable output styles** — knowledge-base, technical, minimal, product, academic, story, dashboard.
- **Optional Baidu Cloud sync** — incremental backup, bidirectional sync, plugin and Obsidian config backup.
- **Diagnostics** — privacy overview, config export, index rebuild, log cleanup.
---
## Status
| Module | Feature | Status | Notes |
| --- | --- | --- | --- |
| Knowledge | AI Assistant | Stable | Insert / replace / append / show via unified entry |
| Knowledge | Reading Projects | Stable | Skeleton, chapter questions, summaries, Feynman |
| Knowledge | Concept Completion | Experimental | Exposed in command panel, preview before write |
| Knowledge | Question Graph | Experimental | Classification + index + Mermaid evolution graph |
| Knowledge | Vault QA | Experimental | Metadata-index retrieval, cited answers, no embeddings |
| Knowledge | Knowledge Debt | Experimental | Dashboard with empty/orphan/unfinished/stale stats |
| Execution | Execution Plan | Experimental | PlanBuilder + PlanExecutor + PlanDraftStore, no rollback yet |
| Execution | Execution Artifact Builder | Experimental | Generic checklist/routine/SOP/plan/review generator from any knowledge context |
| Execution | Scheduled Tasks | Foundation | Types + runner exist; runtime disabled by default in v2.0 |
| Auxiliary | Baidu Sync | Stable | Manual/auto backup and config sync |
| Auxiliary | Multi-provider LLM | Partial | OpenAI-compatible base URL supported |
---
## Quick Start
1. Install the plugin (see [Installation](#installation)).
2. Go to **Settings → IStart-Note-AI → Auxiliary → AI Service** and enter your API key.
3. Click the 🧠 ribbon icon or press the command palette → **IStart-Note-AI: AI 助手**.
4. Type a request in natural language.
---
## Installation
### From community plugins (once available)
1. Settings → Community plugins → Browse.
2. Search **IStart-Note-AI**.
3. Install → Enable.
### Manual (recommended during beta)
Download `main.js`, `manifest.json`, `styles.css` from a [GitHub Release](https://github.com/yan-istart/IStart-Note-AI-Plugin/releases) and place them in `<vault>/.obsidian/plugins/istart-note-ai/`.
> Don't clone the source repo — the bundle lives in `dist/` and is not committed. Use release assets.
### Build from source
```bash
npm ci
npm run build
# → dist/main.js, dist/manifest.json, dist/styles.css
```
---
## Configuration
Settings are organized into three tabs:
| Tab | Key settings |
| --- | --- |
| **Knowledge** | Q&A path, Concepts path, Questions index path, knowledge index status + rebuild |
| **Execution** | Execution log path (read-only), scheduled tasks status (v2.1) |
| **Auxiliary** | API key, Base URL, model, output style, Baidu sync (App ID/Secret, remote path, auto-backup) |
---
## Usage
### Desktop
- 🧠 **Ribbon icon** → command panel (Knowledge / Execution / Auxiliary).
- **Right-click in editor**`IStart-Note-AI: AI 助手` or `知识库问答`.
- **Right-click file**`IStart-Note-AI: AI 助手`.
### Mobile
- 🧠 **Ribbon icon** → command panel.
- Add commands to the mobile toolbar for one-tap access.
---
## Architecture
```
src/
core/
llm/ Unified LLM client + JSON extractor
knowledge/ KnowledgeIndexService (metadata index)
execution/ ExecutionPlan, PlanBuilder, PlanExecutor, PlanDraftStore
artifact/ ExecutionArtifact types, prompt, validation, rendering
scheduler/ ScheduledTask types + runner (disabled in v2.0)
schema.ts SCHEMA_VERSION + helpers
ai/ AI feature modules (assistant, classifier, planner, ...)
features/
assistant/ AI assistant modals
artifact/ Artifact builder, preview, feature controller
concept/ Concept completion + page manager
question/ Question classify + graph manager
reading/ Reading project manager
dashboard/ Knowledge debt modal
sync/ Baidu sync
command-panel/ Unified command panel
vault/ Vault writer (conflict-safe)
settings/ Settings tab (tabbed layout)
actions/ Action registry + definitions
main.ts
```
---
## Privacy
AI features send your selection and partial note context to the configured chat-completions endpoint. Sync features upload to your own Baidu Pan. No telemetry. No plugin-operated servers. Full details in [PRIVACY.md](./PRIVACY.md).
---
## Roadmap
### v2.0 — Knowledge System Foundation (current)
- Three-module product structure: Knowledge / Execution / Auxiliary.
- Vault-wide lightweight knowledge index.
- Concept completion and question graph in command panel.
- Knowledge debt dashboard.
- Basic execution plan and execution logs.
- Settings page with tabbed navigation.
- Open-source governance and privacy docs.
### v2.1 — Execution MVP
- Scheduled tasks runtime (knowledge-debt scan, auto-backup).
- Execution plan preview modal with diff.
- Safer policy: AI writes always produce plan-only by default.
- Execution history view.
### v2.2 — Trust & Control
- Rollback for recent executions.
- More granular privacy controls.
- Optional local vector index for richer vault QA.
### v3.0 — Integrations
- Tasks plugin / Periodic Notes integration.
- GitHub Issues / Linear / Todoist export.
- Multi-vault support.
---
## Contributing
PRs and issues welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md). Security issues: [SECURITY.md](./SECURITY.md).
## License
MIT. See [LICENSE](./LICENSE).

183
README.zh-CN.md Normal file
View file

@ -0,0 +1,183 @@
# IStart-Note-AI
<p align="center">
<strong>把 Obsidian 笔记变成"知识 → 执行"的个人系统。</strong>
</p>
<p align="center">
<a href="./README.md">English</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#隐私说明">隐私说明</a> ·
<a href="#路线图">路线图</a>
</p>
<p align="center">
<img alt="Version" src="https://img.shields.io/github/v/release/yan-istart/IStart-Note-AI-Plugin?include_prereleases">
<img alt="License" src="https://img.shields.io/github/license/yan-istart/IStart-Note-AI-Plugin">
<img alt="CI" src="https://img.shields.io/github/actions/workflow/status/yan-istart/IStart-Note-AI-Plugin/ci.yml?branch=main">
<img alt="Obsidian" src="https://img.shields.io/badge/Obsidian-1.7.2%2B-7C3AED">
</p>
---
IStart-Note-AI 是一个面向 Obsidian 的 AI 插件,围绕 **知识沉淀、执行计划、同步辅助** 三个模块,帮助你把零散笔记转化为可检索、可关联、可执行的个人知识系统。
一个统一 AI 入口,连接三类能力:知识沉淀、执行计划、辅助同步。
> [!warning] 测试版
> v2.0.0 引入了重大架构变更。Frontmatter schema 和定时任务模型尚未稳定。升级前请备份 Vault。
---
## 三大模块
### 1. 知识 Knowledge
构建和维护结构化知识库。
- **提问**并生成 Q&A 笔记,自动提取概念和关系。
- **问题分类**new / refinement / expansion维护问题演化图。
- **概念页创建与补全**:定义、解释、示例、关联概念、领域 MOC 索引。
- **阅读项目**:全书骨架、章节预设问题、章节总结、费曼测试。
- **知识库问答**:基于 Vault 索引检索回答,附带 `[[来源]]` 引用。
- **知识债务看板**:空概念、孤立问题、未完成阅读、长期草稿一目了然。
### 2. 执行 Execution
把知识转化为可审查的行动。
- **生成执行计划**:预览所有 Vault 修改后再执行。
- **执行日志**:自动记录到 `Knowledge/_Executions/`
- **定时任务**MVP每日知识债务扫描、自动百度备份。
- **安全优先**AI 写入需要确认,批量操作有上限,高风险计划强制二次确认。
- 未来diff 预览、回滚、任务集成。
### 3. 辅助 Auxiliary
跨设备和多服务的基础支撑。
- **OpenAI 兼容 LLM**:默认 DeepSeek切换 Base URL 可用其他服务。
- **输出风格可选**:知识库、技术、极简、产品、学术、叙事、仪表盘。
- **百度网盘同步**(可选):增量备份、双向同步、插件和 Obsidian 配置备份。
- **诊断与隐私**:查看数据流说明、导出配置、重建索引、清理日志。
---
## 状态
| 模块 | 功能 | 状态 | 说明 |
| --- | --- | --- | --- |
| 知识 | AI 助手 | 稳定 | 插入 / 替换 / 追加 / 仅展示 |
| 知识 | 阅读项目 | 稳定 | 骨架、章节问题、总结、费曼 |
| 知识 | 概念页补全 | 实验中 | 已接入命令面板,预览后写入 |
| 知识 | 问题图谱 | 实验中 | 分类 + 索引 + Mermaid 演化图 |
| 知识 | 知识库问答 | 实验中 | 元数据索引检索,无 embedding |
| 知识 | 知识债务 | 实验中 | 空概念 / 孤立问题 / 草稿统计 |
| 执行 | 执行计划 | 实验中 | PlanBuilder + Executor无回滚 |
| 执行 | 定时任务 | 规划中 | 类型定义完成runner 开发中 |
| 辅助 | 百度同步 | 稳定 | 手动/自动备份与配置同步 |
| 辅助 | 多 Provider | 部分 | 支持 OpenAI 兼容 Base URL |
---
## 快速开始
1. 安装插件(见下方安装说明)。
2. 进入**设置 → IStart-Note-AI → 辅助 → AI 服务**,输入 API Key。
3. 点击侧边栏 🧠 图标或命令面板 → **IStart-Note-AI: AI 助手**
4. 用自然语言输入指令。
---
## 安装
### 社区插件商店(审核后可用)
1. 设置 → 第三方插件 → 浏览 → 搜索 **IStart-Note-AI**
2. 安装 → 启用。
### 手动安装(测试期推荐)
从 [GitHub Release](https://github.com/yan-istart/IStart-Note-AI-Plugin/releases) 下载 `main.js`、`manifest.json`、`styles.css`,放到 `<Vault>/.obsidian/plugins/istart-note-ai/`
### 从源码构建
```bash
npm ci && npm run build
# → dist/main.js, dist/manifest.json, dist/styles.css
```
---
## 设置
设置页按三个标签组织:
| 标签 | 主要设置 |
| --- | --- |
| **知识** | Q&A 路径、概念路径、问题索引路径、知识索引状态与重建 |
| **执行** | 执行日志目录、安全策略、定时任务v2.1 |
| **辅助** | API Key、Base URL、模型、输出风格、百度同步、隐私说明 |
---
## 使用
### 桌面
- 🧠 侧边栏图标 → 命令面板(知识 / 执行 / 辅助)。
- 编辑器右键 → `IStart-Note-AI: AI 助手``知识库问答`
- 文件列表右键 → `IStart-Note-AI: AI 助手`
### 移动端
- 🧠 侧边栏图标 → 命令面板。
- 把命令添加到移动工具栏。
---
## 隐私说明
AI 功能会把你选中的内容和部分笔记上下文发送到所配置的 API 端点。同步功能只上传到你自己的百度网盘。无遥测、无插件方服务器。详见 [PRIVACY.md](./PRIVACY.md) / [PRIVACY.zh-CN.md](./PRIVACY.zh-CN.md)。
---
## 路线图
### v2.0 — 知识系统基础版(当前)
- 三模块产品结构:知识 / 执行 / 辅助。
- Vault 级轻量知识索引。
- 概念补全和问题图谱接入命令面板。
- 知识债务看板。
- 基础执行计划和执行日志。
- 分组设置页。
- 开源治理和隐私文档。
### v2.1 — 执行 MVP
- 定时任务运行时。
- 执行计划预览 Modal + diff。
- AI 写入默认 plan-only。
- 执行历史视图。
### v2.2 — 信任与控制
- 回滚最近执行。
- 更细粒度的隐私控制。
- 可选本地向量索引。
### v3.0 — 集成
- Tasks / Periodic Notes 集成。
- GitHub Issues / Linear / Todoist 导出。
---
## 贡献
欢迎提 Issue / PR。请先阅读 [CONTRIBUTING.md](./CONTRIBUTING.md)。安全问题见 [SECURITY.md](./SECURITY.md)。
## 协议
MIT。详见 [LICENSE](./LICENSE)。

122
Readme.md
View file

@ -1,122 +0,0 @@
# IStart-Note-AI
AI-powered knowledge management plugin for Obsidian. One unified AI assistant that helps you build structured notes, read books effectively, and visualize knowledge — all through natural language.
---
## Features
### AI Assistant (unified entry)
One input handles everything. Select text or place your cursor, then tell the AI what you want:
- **Expand** — select text, ask to expand or rewrite
- **Explain** — select a term, ask to explain
- **Diagrams** — describe what you want (flowchart, sequence, state, class, ER, Gantt)
- **Formulas** — describe a math expression, get LaTeX
- **Complete** — fill empty sections based on context
- **Continue** — write more from cursor position
- **Summarize** — summarize the current document
- **Beautify** — restructure existing content with callouts, links, and visual breaks
- **Anything else** — just describe it in natural language
Quick tags for common actions: `[扩写]` `[解释]` `[画图]` `[补全]` `[续写]` `[总结]` `[公式]` `[时序图]`
### Structured Output
AI generates content in professional knowledge-base style:
- Short paragraphs with visual breaks
- Obsidian Callouts (`> [!summary]`, `> [!warning]`, `> [!tip]`)
- Automatic Mermaid diagrams where appropriate
- Auto-linked `[[concepts]]` to existing pages
- Configurable output style (technical, minimal, academic, etc.)
### Reading Projects
Turn any book into a structured study plan:
1. Enter book title (optionally paste table of contents)
2. AI generates reading roadmap, chapter relationships, and pre-reading questions
3. Record notes as you read
4. Generate chapter summaries and take Feynman tests
5. Supports resume if generation is interrupted
### Knowledge Organization
- Concepts auto-organized into domain subdirectories after completion
- Domain MOC index pages with Mermaid overview graphs
- Relationship diagrams in concept pages
- Question evolution graphs in question index
### Baidu Cloud Sync
- Incremental backup / bidirectional sync / force overwrite
- Plugin and Obsidian config backup (toolbar, hotkeys, appearance)
- Auto backup after note generation
---
## Requirements
- Obsidian 1.7.2 or later
- A [DeepSeek API key](https://platform.deepseek.com)
---
## Installation
### From community plugins (recommended)
1. Settings → Community plugins → Browse
2. Search **IStart-Note-AI**
3. Install → Enable
### Manual
1. Build: `npm install && npm run build`
2. Copy `dist/` contents to `.obsidian/plugins/istart-note-ai/`
3. Enable in Settings → Community plugins
---
## Configuration
Settings → IStart-Note-AI:
| Setting | Description | Default |
|---------|-------------|---------|
| API Key | DeepSeek API key | — |
| Base URL | API endpoint | `https://api.deepseek.com` |
| Model | `deepseek-v4-flash` or `deepseek-v4-pro` | `deepseek-v4-flash` |
| Output style | Knowledge-base, technical, minimal, product, academic, story, dashboard | Knowledge-base |
| Q&A folder | Where Q&A notes are saved | `Knowledge/Q&A` |
| Concepts folder | Where concept pages are saved | `Knowledge/Concepts` |
---
## Usage
### Desktop
- **🧠 Ribbon icon** → Opens command panel
- **Right-click in editor** → "IStart-Note-AI: AI 助手"
- **Right-click file in sidebar** → "IStart-Note-AI: AI 助手"
### Mobile
- **🧠 Ribbon icon** → Opens command panel
- Add `AI 助手` to mobile toolbar for one-tap access
### Workflow
1. Select text (optional)
2. Click 🧠 or right-click → AI 助手
3. Type your request (or tap a quick tag, or leave blank for auto-detect)
4. Preview result → Confirm
---
## License
MIT. See [LICENSE](LICENSE).

37
SECURITY.md Normal file
View file

@ -0,0 +1,37 @@
# Security Policy
## Supported versions
Only the latest released version of IStart-Note-AI receives fixes. Please make sure you are on the latest release before reporting an issue.
| Version | Supported |
| --- | --- |
| latest release | yes |
| anything older | no |
## Reporting a vulnerability
If you believe you have found a security issue, please **do not** open a public GitHub issue. Instead:
1. Use GitHub's [private vulnerability reporting](https://github.com/yan-istart/IStart-Note-AI-Plugin/security/advisories/new) for this repository.
2. Provide a clear description of the issue, the affected version, and reproduction steps.
3. Allow up to 14 days for an initial response.
If GitHub private reporting is unavailable, open an issue with the title "Security issue — please contact me" and avoid technical details. A maintainer will reach out for a private channel.
## Scope
In scope:
- Code inside this repository (the plugin itself).
- Data flow as documented in [PRIVACY.md](./PRIVACY.md).
Out of scope:
- Vulnerabilities in DeepSeek, Baidu Pan, or any other third-party service.
- Issues caused by user-modified builds or third-party forks.
- Theoretical attacks against any TLS endpoint that does not affect this plugin specifically.
## Disclosure
Once a fix is shipped, the advisory will be published with credit to the reporter (unless anonymity is requested). Please coordinate before public disclosure to give users time to upgrade.

View file

@ -1,7 +1,7 @@
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from "module";
import { copyFileSync, mkdirSync } from "fs";
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
@ -42,8 +42,26 @@ const context = await esbuild.context({
if (prod) {
await context.rebuild();
mkdirSync("dist", { recursive: true });
copyFileSync("manifest.json", "dist/manifest.json");
// Generate build number: version+YYYYMMDDHHmm
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const now = new Date();
const buildStamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
].join("");
const buildVersion = `${manifest.version}+${buildStamp}`;
// Write dist/manifest.json with build version
const distManifest = { ...manifest, version: buildVersion };
writeFileSync("dist/manifest.json", JSON.stringify(distManifest, null, 2) + "\n");
copyFileSync("styles.css", "dist/styles.css");
console.log(`Build complete: ${buildVersion}`);
process.exit(0);
} else {
await context.watch();

322
main.js
View file

@ -1,322 +0,0 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/main.ts
var main_exports = {};
__export(main_exports, {
default: () => DeepSeekPlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian4 = require("obsidian");
// src/types.ts
var DEFAULT_SETTINGS = {
apiKey: "",
baseUrl: "https://api.deepseek.com",
model: "deepseek-chat",
savePath: "Knowledge/Q&A",
autoOpenGraph: false
};
// src/DeepSeekClient.ts
var SYSTEM_PROMPT = `\u4F60\u662F\u4E00\u4E2A\u77E5\u8BC6\u56FE\u8C31\u6784\u5EFA\u52A9\u624B\u3002\u7528\u6237\u4F1A\u5411\u4F60\u63D0\u95EE\uFF0C\u4F60\u9700\u8981\uFF1A
1. \u7ED9\u51FA\u6E05\u6670\u7684\u56DE\u7B54
2. \u63D0\u53D6\u5173\u952E\u6982\u5FF5\uFF083-7\u4E2A\uFF09
3. \u8BC6\u522B\u6982\u5FF5\u95F4\u7684\u5173\u7CFB\uFF08\u9650\u4E8E\uFF1A\u5F71\u54CD\u3001\u5C5E\u4E8E\u3001\u5BFC\u81F4\u3001\u4F9D\u8D56\u3001\u5BF9\u7ACB\uFF09
4. \u751F\u6210\u76F8\u5173\u6807\u7B7E\uFF082-5\u4E2A\uFF09
\u4E25\u683C\u6309\u7167\u4EE5\u4E0B JSON \u683C\u5F0F\u8FD4\u56DE\uFF0C\u4E0D\u8981\u6709\u4EFB\u4F55\u5176\u4ED6\u5185\u5BB9\uFF1A
{
"answer": "\u8BE6\u7EC6\u56DE\u7B54",
"concepts": ["\u6982\u5FF5A", "\u6982\u5FF5B"],
"relations": [
{ "from": "\u6982\u5FF5A", "relation": "\u5F71\u54CD", "to": "\u6982\u5FF5B" }
],
"tags": ["\u6807\u7B7E1", "\u6807\u7B7E2"]
}`;
var DeepSeekClient = class {
constructor(settings) {
this.settings = settings;
}
async ask(question) {
var _a, _b, _c;
if (!this.settings.apiKey) {
throw new Error("\u8BF7\u5148\u5728\u63D2\u4EF6\u8BBE\u7F6E\u4E2D\u914D\u7F6E DeepSeek 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: "system", content: SYSTEM_PROMPT },
{ role: "user", content: question }
],
temperature: 0.7
})
});
if (!response.ok) {
const err = await response.text();
throw new Error(`DeepSeek API \u9519\u8BEF: ${response.status} - ${err}`);
}
const data = await response.json();
const content = (_c = (_b = (_a = data.choices) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content;
if (!content) {
throw new Error("DeepSeek \u8FD4\u56DE\u5185\u5BB9\u4E3A\u7A7A");
}
return this.parseResponse(content);
}
parseResponse(content) {
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/(\{[\s\S]*\})/);
const jsonStr = jsonMatch ? jsonMatch[1] : content;
try {
const parsed = JSON.parse(jsonStr.trim());
return {
answer: parsed.answer || "",
concepts: Array.isArray(parsed.concepts) ? parsed.concepts : [],
relations: Array.isArray(parsed.relations) ? parsed.relations : [],
tags: Array.isArray(parsed.tags) ? parsed.tags : []
};
} catch (e) {
return {
answer: content,
concepts: [],
relations: [],
tags: []
};
}
}
};
// src/VaultWriter.ts
var import_obsidian = require("obsidian");
var VaultWriter = class {
constructor(app, settings) {
this.app = app;
this.settings = settings;
}
async writeQANote(question, response) {
const date = new Date().toISOString().slice(0, 10);
const safeTitle = this.sanitizeFilename(question).slice(0, 50);
const filename = `${date}-${safeTitle}.md`;
const folderPath = (0, import_obsidian.normalizePath)(this.settings.savePath);
const filePath = (0, import_obsidian.normalizePath)(`${folderPath}/${filename}`);
await this.ensureFolder(folderPath);
const content = this.buildNoteContent(question, response);
const file = await this.app.vault.create(filePath, content);
for (const concept of response.concepts) {
await this.ensureConceptNote(concept);
}
return file;
}
buildNoteContent(question, response) {
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}
## Question
${question}
## Answer
${response.answer}
## Concepts
${conceptLinks || "- \u6682\u65E0"}
## Relations
${relationLines || "- \u6682\u65E0"}
## Tags
${tagLine || "\u6682\u65E0\u6807\u7B7E"}
`;
}
async ensureConceptNote(concept) {
const folderPath = (0, import_obsidian.normalizePath)("Knowledge/Concepts");
const filePath = (0, import_obsidian.normalizePath)(`${folderPath}/${concept}.md`);
await this.ensureFolder(folderPath);
const exists = this.app.vault.getAbstractFileByPath(filePath);
if (!exists) {
await this.app.vault.create(
filePath,
`# ${concept}
## \u5B9A\u4E49
## \u5173\u8054
## \u6765\u6E90
`
);
}
}
async ensureFolder(path) {
const exists = this.app.vault.getAbstractFileByPath(path);
if (!exists) {
await this.app.vault.createFolder(path);
}
}
sanitizeFilename(name) {
return name.replace(/[\\/:*?"<>|#\[\]]/g, "-").trim();
}
};
// src/QuestionModal.ts
var import_obsidian2 = require("obsidian");
var QuestionModal = class extends import_obsidian2.Modal {
constructor(app, onSubmit) {
super(app);
this.question = "";
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: "\u5411 DeepSeek \u63D0\u95EE" });
const textArea = contentEl.createEl("textarea", {
attr: {
placeholder: "\u8F93\u5165\u4F60\u7684\u95EE\u9898...",
rows: "4",
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 import_obsidian2.Setting(contentEl).addButton(
(btn) => btn.setButtonText("\u63D0\u95EE (Ctrl+Enter)").setCta().onClick(() => this.submit())
).addButton(
(btn) => btn.setButtonText("\u53D6\u6D88").onClick(() => this.close())
);
setTimeout(() => textArea.focus(), 50);
}
submit() {
const q = this.question.trim();
if (!q)
return;
this.close();
this.onSubmit(q);
}
onClose() {
this.contentEl.empty();
}
};
// src/SettingsTab.ts
var import_obsidian3 = require("obsidian");
var DeepSeekSettingsTab = class extends import_obsidian3.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "DeepSeek Knowledge Graph \u8BBE\u7F6E" });
new import_obsidian3.Setting(containerEl).setName("API Key").setDesc("DeepSeek API Key\uFF08\u5728 platform.deepseek.com \u83B7\u53D6\uFF09").addText(
(text) => text.setPlaceholder("sk-...").setValue(this.plugin.settings.apiKey).onChange(async (value) => {
this.plugin.settings.apiKey = value.trim();
await this.plugin.saveSettings();
})
);
new import_obsidian3.Setting(containerEl).setName("Base URL").setDesc("API \u5730\u5740\uFF0C\u9ED8\u8BA4 https://api.deepseek.com").addText(
(text) => text.setPlaceholder("https://api.deepseek.com").setValue(this.plugin.settings.baseUrl).onChange(async (value) => {
this.plugin.settings.baseUrl = value.trim() || "https://api.deepseek.com";
await this.plugin.saveSettings();
})
);
new import_obsidian3.Setting(containerEl).setName("\u6A21\u578B").setDesc("\u9009\u62E9\u4F7F\u7528\u7684 DeepSeek \u6A21\u578B").addDropdown(
(drop) => drop.addOption("deepseek-chat", "deepseek-chat\uFF08\u63A8\u8350\uFF09").addOption("deepseek-reasoner", "deepseek-reasoner\uFF08\u6DF1\u5EA6\u63A8\u7406\uFF09").setValue(this.plugin.settings.model).onChange(async (value) => {
this.plugin.settings.model = value;
await this.plugin.saveSettings();
})
);
new import_obsidian3.Setting(containerEl).setName("\u7B14\u8BB0\u4FDD\u5B58\u8DEF\u5F84").setDesc("Q&A \u7B14\u8BB0\u5B58\u50A8\u76EE\u5F55\uFF08\u76F8\u5BF9\u4E8E Vault \u6839\u76EE\u5F55\uFF09").addText(
(text) => text.setPlaceholder("Knowledge/Q&A").setValue(this.plugin.settings.savePath).onChange(async (value) => {
this.plugin.settings.savePath = value.trim() || "Knowledge/Q&A";
await this.plugin.saveSettings();
})
);
new import_obsidian3.Setting(containerEl).setName("\u81EA\u52A8\u6253\u5F00 Graph View").setDesc("\u751F\u6210\u7B14\u8BB0\u540E\u81EA\u52A8\u6253\u5F00\u56FE\u8C31\u89C6\u56FE").addToggle(
(toggle) => toggle.setValue(this.plugin.settings.autoOpenGraph).onChange(async (value) => {
this.plugin.settings.autoOpenGraph = value;
await this.plugin.saveSettings();
})
);
}
};
// src/main.ts
var DeepSeekPlugin = class extends import_obsidian4.Plugin {
async onload() {
await this.loadSettings();
this.addRibbonIcon("brain", "DeepSeek \u63D0\u95EE", () => {
this.openQuestionModal();
});
this.addCommand({
id: "ask-deepseek",
name: "\u5411 DeepSeek \u63D0\u95EE\u5E76\u751F\u6210\u77E5\u8BC6\u7B14\u8BB0",
hotkeys: [{ modifiers: ["Mod", "Shift"], key: "d" }],
callback: () => this.openQuestionModal()
});
this.addSettingTab(new DeepSeekSettingsTab(this.app, this));
}
openQuestionModal() {
new QuestionModal(this.app, (question) => {
this.processQuestion(question);
}).open();
}
async processQuestion(question) {
const notice = new import_obsidian4.Notice("\u23F3 DeepSeek \u601D\u8003\u4E2D...", 0);
try {
const client = new DeepSeekClient(this.settings);
const response = await client.ask(question);
const writer = new VaultWriter(this.app, this.settings);
const file = await writer.writeQANote(question, response);
notice.hide();
new import_obsidian4.Notice(`\u2705 \u7B14\u8BB0\u5DF2\u751F\u6210\uFF1A${file.name}`);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
if (this.settings.autoOpenGraph) {
this.app.commands.executeCommandById("graph:open");
}
} catch (err) {
notice.hide();
new import_obsidian4.Notice(`\u274C \u9519\u8BEF\uFF1A${err.message}`);
console.error("[DeepSeek Plugin]", err);
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
};

View file

@ -1,7 +1,7 @@
{
"id": "istart-note-ai",
"name": "IStart-Note-AI",
"version": "1.8.3",
"version": "2.0.0",
"minAppVersion": "1.7.2",
"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",

20
package-lock.json generated
View file

@ -1,20 +1,19 @@
{
"name": "istart-note-ai",
"version": "1.5.6",
"version": "1.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "istart-note-ai",
"version": "1.5.6",
"version": "1.8.3",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"obsidian": "^1.7.2",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
@ -935,19 +934,6 @@
"node": ">=8"
}
},
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",

View file

@ -1,24 +1,46 @@
{
"name": "istart-note-ai",
"version": "1.8.3",
"description": "IStart-Note-AI: DeepSeek-powered knowledge graph plugin for Obsidian",
"version": "2.0.0",
"description": "IStart-Note-AI: an Obsidian plugin that turns notes into a structured personal knowledge system, powered by DeepSeek and other OpenAI-compatible LLMs.",
"main": "main.js",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"typecheck": "tsc -noEmit -skipLibCheck",
"build": "npm run typecheck && node esbuild.config.mjs production",
"test": "echo \"no tests yet — see CONTRIBUTING.md for the planned vitest setup\" && exit 0",
"ci": "npm run typecheck && npm run build",
"version": "node scripts/version-bump.mjs && git add manifest.json versions.json",
"release": "bash scripts/release.sh",
"submit": "bash scripts/submit-plugin.sh"
},
"keywords": [],
"author": "",
"keywords": [
"obsidian",
"obsidian-plugin",
"ai",
"deepseek",
"knowledge-management",
"knowledge-graph",
"personal-knowledge-management",
"pkm",
"notes"
],
"author": "Yan",
"license": "MIT",
"homepage": "https://github.com/yan-istart/IStart-Note-AI-Plugin#readme",
"bugs": {
"url": "https://github.com/yan-istart/IStart-Note-AI-Plugin/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/yan-istart/IStart-Note-AI-Plugin.git"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"obsidian": "^1.7.2",
"tslib": "2.4.0",
"typescript": "4.7.4"
}

View file

@ -55,7 +55,7 @@ check_prerequisites() {
[[ -f "$PROJECT_DIR/manifest.json" ]] || die "manifest.json not found."
if [[ ! -f "$PROJECT_DIR/README.md" ]] && [[ ! -f "$PROJECT_DIR/Readme.md" ]]; then
if [[ ! -f "$PROJECT_DIR/README.md" ]]; then
die "README.md not found. It is required for plugin submission."
fi

BIN
src/.DS_Store vendored

Binary file not shown.

View file

@ -1,55 +1,198 @@
import { ActionDef } from "./types";
/**
*
* 3 "AI 助手"
* All actions, organized by domain: Knowledge / Execution / Auxiliary.
*
* The "AI 助手" is placed in Auxiliary as a cross-cutting entry point.
* The command panel renders it as a pinned top-level button above the grouped actions.
*
* Icons use Lucide names (https://lucide.dev) which Obsidian supports natively.
*/
export const ALL_ACTIONS: ActionDef[] = [
// ── 核心入口AI 助手(覆盖 90% 场景) ────────────────────
// ══════════════════════════════════════════════════════════════
// KNOWLEDGE
// ══════════════════════════════════════════════════════════════
{
id: "vault-qa",
label: "知识库问答",
icon: "book-open-check",
description: "基于 Vault 检索回答,附带来源引用",
domain: "knowledge",
section: "retrieval",
when: { always: true },
showIn: ["panel", "editor-menu"],
experimental: true,
run: (ctx) => { ctx.plugin.openVaultQA(); },
},
{
id: "question-with-graph",
label: "知识提问",
icon: "message-circle-question",
description: "提问 → 自动分类 → 生成 Q&A → 更新问题图谱",
domain: "knowledge",
section: "question",
when: { always: true },
showIn: ["panel"],
run: (ctx) => { ctx.plugin.openQuestionWithGraph(); },
},
{
id: "complete-current-concept",
label: "补全当前概念页",
icon: "puzzle",
description: "为当前打开的空概念页生成定义、解释、示例、关联",
domain: "knowledge",
section: "concept",
when: { always: true },
showIn: ["panel", "editor-menu"],
experimental: true,
run: (ctx) => { ctx.plugin.openCompleteCurrentConcept(); },
},
{
id: "scan-empty-concepts",
label: "扫描空概念页",
icon: "scan-search",
description: "扫描 Vault 中所有空概念页,批量补全",
domain: "knowledge",
section: "concept",
when: { always: true },
showIn: ["panel"],
experimental: true,
run: (ctx) => { ctx.plugin.openScanEmptyConcepts(); },
},
{
id: "new-reading-project",
label: "新建阅读项目",
icon: "book-marked",
description: "输入书名,生成阅读地图",
domain: "knowledge",
section: "reading",
when: { always: true },
showIn: ["panel"],
run: (ctx) => { ctx.plugin.openNewReadingProject(); },
},
{
id: "create-artifact",
label: "从当前知识生成执行资产",
icon: "file-check",
description: "检查表、SOP、例行流程、执行计划、复盘表",
domain: "knowledge",
section: "retrieval",
when: { always: true },
showIn: ["panel", "editor-menu"],
run: (ctx) => { ctx.plugin.openArtifactBuilder(); },
},
{
id: "knowledge-debt",
label: "知识债务看板",
icon: "activity",
description: "空概念、孤立问题、未完成阅读、长期草稿",
domain: "knowledge",
section: "debt",
when: { always: true },
showIn: ["panel"],
experimental: true,
run: (ctx) => { ctx.plugin.openKnowledgeDebt(); },
},
// ══════════════════════════════════════════════════════════════
// EXECUTION
// ══════════════════════════════════════════════════════════════
{
id: "generate-plan",
label: "从当前笔记生成执行计划",
icon: "list-todo",
description: "AI 分析笔记内容,提取可执行行动项",
domain: "execution",
section: "plan",
when: { always: true },
showIn: ["panel", "editor-menu"],
experimental: true,
run: (ctx) => { ctx.plugin.openGeneratePlan(); },
},
{
id: "view-pending-plans",
label: "查看待确认计划",
icon: "clipboard-list",
description: "打开最新的待确认执行计划",
domain: "execution",
section: "plan",
when: { always: true },
showIn: ["panel"],
run: (ctx) => { ctx.plugin.openPendingPlans(); },
},
{
id: "confirm-execute-plan",
label: "确认执行此计划",
icon: "play",
description: "执行当前打开的待确认计划",
domain: "execution",
section: "plan",
when: { fileType: ["execution-plan"] },
showIn: ["panel", "editor-menu"],
risk: "medium",
run: (ctx) => { void ctx.plugin.confirmAndExecutePlan(); },
},
{
id: "view-execution-logs",
label: "查看执行日志",
icon: "scroll-text",
description: "打开 Knowledge/_Executions 中最新的执行记录",
domain: "execution",
section: "logs",
when: { always: true },
showIn: ["panel"],
run: (ctx) => { ctx.plugin.openExecutionLogs(); },
},
{
id: "scheduled-tasks",
label: "定时任务",
icon: "timer",
description: "查看和管理定时任务v2.1 启用运行时)",
domain: "execution",
section: "scheduler",
when: { always: true },
showIn: ["panel"],
experimental: true,
run: (ctx) => { ctx.plugin.openScheduledTasks(); },
},
// ══════════════════════════════════════════════════════════════
// AUXILIARY
// ══════════════════════════════════════════════════════════════
{
id: "ai-assistant",
label: "AI 助手",
icon: "sparkles",
description: "选中文字或输入指令AI 智能执行",
group: "general",
domain: "auxiliary",
section: "assistant",
when: { always: true },
showIn: ["panel", "editor-menu", "file-menu"],
run: (ctx) => { ctx.plugin.openAssistant(); },
},
// ── 阅读项目(需要专门表单) ──────────────────────────────
{
id: "new-reading-project",
label: "新建阅读项目",
icon: "book-open",
description: "输入书名,生成阅读地图",
group: "general",
when: { always: true },
showIn: ["panel"],
run: (ctx) => { ctx.plugin.openNewReadingProject(); },
},
// ── 百度云同步(独立功能) ────────────────────────────────
{
id: "baidu-sync",
label: "百度云同步",
icon: "cloud",
description: "备份 / 恢复 / 同步",
group: "general",
when: { always: true },
showIn: ["panel"],
run: (ctx) => { ctx.plugin.openBaiduSyncModal(); },
},
// ── 美化当前文档 ──────────────────────────────────────────
{
id: "beautify-note",
label: "美化当前文档",
icon: "wand",
icon: "paintbrush",
description: "整理结构、插入 Callout、生成双链",
group: "general",
domain: "auxiliary",
section: "document",
when: { always: true },
showIn: ["panel", "editor-menu", "file-menu"],
run: (ctx) => { void ctx.plugin.beautifyCurrentNote(); },
},
{
id: "baidu-sync",
label: "百度云同步",
icon: "cloud-upload",
description: "备份 / 恢复 / 同步",
domain: "auxiliary",
section: "sync",
when: { always: true },
showIn: ["panel"],
run: (ctx) => { ctx.plugin.openBaiduSyncModal(); },
},
];

View file

@ -1,6 +1,6 @@
import { Notice, TFile } from "obsidian";
import type DeepSeekPlugin from "../main";
import { ActionDef, ActionContext, GROUP_TITLES, GROUP_ORDER } from "./types";
import { ActionDef, ActionContext, DOMAIN_TITLES, DOMAIN_ORDER } from "./types";
import { CommandPanelModal } from "../features/command-panel/CommandPanelModal";
import type { PanelGroup, PanelAction } from "../features/command-panel/CommandPanelModal";
@ -95,18 +95,37 @@ function openPanel(plugin: DeepSeekPlugin, actions: ActionDef[]) {
ctx.selection = editor.getSelection().trim();
}
// Separate pinned action (AI 助手) from grouped actions
const pinnedAction = actions.find((a) => a.id === "ai-assistant");
const groupedActions = actions.filter((a) => a.id !== "ai-assistant");
const groups: PanelGroup[] = [];
for (const groupId of GROUP_ORDER) {
const groupActions = actions.filter(
(a) => a.group === groupId && a.showIn.includes("panel") && evaluateWhen(a.when, ctx)
);
if (groupActions.length === 0) continue;
// Add pinned as first "group" with a special title
if (pinnedAction && evaluateWhen(pinnedAction.when, ctx)) {
groups.push({
title: GROUP_TITLES[groupId],
actions: groupActions.map((a) => ({
title: "入口",
actions: [{
id: pinnedAction.id,
icon: pinnedAction.icon,
label: pinnedAction.label,
description: pinnedAction.description,
callback: () => pinnedAction.run(ctx),
}],
});
}
for (const domainId of DOMAIN_ORDER) {
const domainActions = groupedActions.filter(
(a) => a.domain === domainId && a.showIn.includes("panel") && evaluateWhen(a.when, ctx)
);
if (domainActions.length === 0) continue;
groups.push({
title: DOMAIN_TITLES[domainId],
actions: domainActions.map((a) => ({
id: a.id,
icon: a.icon,
label: a.label,
label: a.label + (a.experimental ? " ⚗️" : ""),
description: a.description,
callback: () => a.run(ctx),
})),

View file

@ -7,30 +7,44 @@ export interface ActionContext {
app: App;
editor: Editor | null;
activeFile: TFile | null;
selection: string; // 选中文字trim 后)
fileContent: string; // 当前文件全文
fileType: string | undefined; // frontmatter.type
filePath: string; // 当前文件路径
sectionName: string | null; // 光标所在 section 名
// file-menu 专用:右键的目标文件(可能不是当前打开的文件)
selection: string;
fileContent: string;
fileType: string | undefined;
filePath: string;
sectionName: string | null;
targetFile: TFile | null;
}
/** 可见性条件 */
export interface ActionWhen {
always?: boolean; // 始终可见
hasSelection?: boolean; // 需要有选中文字
noSelection?: boolean; // 需要没有选中文字
fileType?: string[]; // frontmatter type 匹配其一
filePath?: string; // 文件路径包含此字符串
inSection?: boolean; // 光标在某个 ## section 内
always?: boolean;
hasSelection?: boolean;
noSelection?: boolean;
fileType?: string[];
filePath?: string;
inSection?: boolean;
}
/** 动作出现的入口 */
export type ActionEntry = "panel" | "editor-menu" | "file-menu";
/** 面板分组 */
export type ActionGroup = "general" | "selection" | "edit" | "concept" | "reading" | "sync" | "document";
/** 三大产品域 */
export type ActionDomain = "knowledge" | "execution" | "auxiliary";
/** 细分领域(用于面板二级分组、设置定位等) */
export type ActionSection =
| "question"
| "concept"
| "reading"
| "retrieval"
| "debt"
| "plan"
| "scheduler"
| "logs"
| "sync"
| "assistant"
| "document"
| "settings";
/** 动作定义 */
export interface ActionDef {
@ -38,24 +52,33 @@ export interface ActionDef {
label: string;
icon: string;
description?: string;
group: ActionGroup;
domain: ActionDomain;
section: ActionSection;
when: ActionWhen;
showIn: ActionEntry[];
/** 操作风险 */
risk?: "none" | "low" | "medium" | "high";
/** 是否实验性功能 */
experimental?: boolean;
run: (ctx: ActionContext) => void;
}
/** 分组标题映射 */
export const GROUP_TITLES: Record<ActionGroup, string> = {
general: "通用",
selection: "选中文字",
edit: "编辑",
concept: "概念页",
reading: "阅读",
sync: "同步",
document: "文档工具",
// ── 兼容层:旧 group 映射到新 domain ──────────────────────────
// 保留旧类型名以便 registry 和 panel 平滑迁移
/** @deprecated Use ActionDomain + ActionSection */
export type ActionGroup = ActionDomain;
/** 域标题(面板一级分组) */
export const DOMAIN_TITLES: Record<ActionDomain, string> = {
knowledge: "知识",
execution: "执行",
auxiliary: "辅助",
};
/** 分组排序 */
export const GROUP_ORDER: ActionGroup[] = [
"general", "selection", "edit", "concept", "reading", "sync", "document",
];
/** 域排序 */
export const DOMAIN_ORDER: ActionDomain[] = ["knowledge", "execution", "auxiliary"];
// ── 向后兼容的 GROUP 导出registry.ts 还在用) ───────────────
export const GROUP_TITLES = DOMAIN_TITLES;
export const GROUP_ORDER = DOMAIN_ORDER;

View file

@ -1,5 +1,5 @@
import { requestUrl } from "obsidian";
import { DeepSeekSettings } from "../types";
import { LLMClient } from "../core/llm";
import { ContentClassifier } from "./classifier/ContentClassifier";
import { StructuredPromptBuilder } from "./prompt/StructuredPromptBuilder";
import { MarkdownBeautifier } from "./formatter/MarkdownBeautifier";
@ -31,6 +31,7 @@ export interface AssistantResult {
export class AIAssistant {
private classifier: ContentClassifier;
private promptBuilder: StructuredPromptBuilder;
private llm: LLMClient;
constructor(
private settings: DeepSeekSettings,
@ -39,10 +40,11 @@ export class AIAssistant {
) {
this.classifier = new ContentClassifier();
this.promptBuilder = new StructuredPromptBuilder(outputStyle);
this.llm = new LLMClient(settings);
}
async run(instruction: string, ctx: AssistantContext): Promise<AssistantResult> {
if (!this.settings.apiKey) throw new Error("请先配置 API Key");
this.llm.ensureApiKey();
// 1. 分类内容类型
const contentType = this.classifier.classify({
@ -111,26 +113,7 @@ export class AIAssistant {
}
private async callAPI(systemPrompt: string, userPrompt: 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: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
temperature: 0.5,
}),
throw: false,
});
if (res.status !== 200) throw new Error(`API 错误: ${res.status}`);
return res.json.choices?.[0]?.message?.content ?? "";
return this.llm.chat({ systemPrompt, userPrompt, temperature: 0.5 });
}
private parseResponse(raw: string, ctx: AssistantContext): { mode: AssistantResult["mode"]; content: string } {

View file

@ -1,5 +1,5 @@
import { requestUrl } from "obsidian";
import { DeepSeekSettings, CompletionDepth, ConceptCompletionResult } from "../types";
import { LLMClient, parseJsonSafe } from "../core/llm";
const LIGHT_PROMPT = `你是一个个人知识图谱助手。请为以下概念生成简明的定义和关联概念。
@ -49,17 +49,17 @@ const STANDARD_PROMPT = `你是一个个人知识图谱助手。请根据给定
}`;
export class ConceptCompleter {
constructor(private settings: DeepSeekSettings) {}
private llm: LLMClient;
constructor(settings: DeepSeekSettings) {
this.llm = new LLMClient(settings);
}
async complete(
concept: string,
depth: CompletionDepth,
context: { sourceQuestion?: string; sourceAnswer?: string; relatedConcepts?: string[] }
): Promise<ConceptCompletionResult> {
if (!this.settings.apiKey) {
throw new Error("请先在插件设置中配置 DeepSeek API Key");
}
const template = depth === "light" ? LIGHT_PROMPT : STANDARD_PROMPT;
const prompt = template
.replace("{{concept}}", concept)
@ -67,47 +67,13 @@ export class ConceptCompleter {
.replace("{{source_answer}}", context.sourceAnswer || "无")
.replace("{{related_concepts}}", (context.relatedConcepts || []).join("、") || "无");
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(`DeepSeek API 错误: ${res.status} - ${res.text}`);
}
const data = res.json;
const content = data.choices?.[0]?.message?.content ?? "";
const content = await this.llm.chat({ userPrompt: prompt, temperature: 0.5 });
return this.parse(content);
}
private parse(content: string): ConceptCompletionResult {
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()) as Record<string, unknown>;
return {
definition: (p.definition as string) || "",
explanation: (p.explanation as string) || "",
examples: Array.isArray(p.examples) ? p.examples as string[] : [],
related_concepts: Array.isArray(p.related_concepts) ? p.related_concepts as ConceptCompletionResult["related_concepts"] : [],
related_questions: Array.isArray(p.related_questions) ? p.related_questions as string[] : [],
tags: Array.isArray(p.tags) ? p.tags as string[] : [],
domain: typeof p.domain === "string" ? p.domain : undefined,
};
} catch {
const p = parseJsonSafe<Record<string, unknown> | null>(content, null);
if (!p) {
return {
definition: content,
explanation: "",
@ -117,5 +83,16 @@ export class ConceptCompleter {
tags: [],
};
}
return {
definition: (p.definition as string) || "",
explanation: (p.explanation as string) || "",
examples: Array.isArray(p.examples) ? (p.examples as string[]) : [],
related_concepts: Array.isArray(p.related_concepts)
? (p.related_concepts as ConceptCompletionResult["related_concepts"])
: [],
related_questions: Array.isArray(p.related_questions) ? (p.related_questions as string[]) : [],
tags: Array.isArray(p.tags) ? (p.tags as string[]) : [],
domain: typeof p.domain === "string" ? p.domain : undefined,
};
}
}

View file

@ -1,5 +1,5 @@
import { requestUrl } from "obsidian";
import { DeepSeekSettings, ContextQAInput, ContextQAResponse, Relation } from "../types";
import { LLMClient, parseJsonSafe } from "../core/llm";
const buildPrompt = (input: ContextQAInput): string => `请基于以下上下文回答问题。
@ -29,53 +29,33 @@ ${input.question}
}`;
export class ContextQAClient {
constructor(private settings: DeepSeekSettings) {}
private llm: LLMClient;
constructor(settings: DeepSeekSettings) {
this.llm = new LLMClient(settings);
}
async ask(input: ContextQAInput): Promise<ContextQAResponse> {
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: buildPrompt(input) }],
temperature: 0.6,
}),
throw: false,
const content = await this.llm.chat({
userPrompt: buildPrompt(input),
temperature: 0.6,
});
if (res.status !== 200) {
throw new Error(`API 错误: ${res.status} - ${res.text}`);
}
const data = res.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()) as Record<string, unknown>;
return {
answer: (p.answer as string) || "",
concepts: Array.isArray(p.concepts) ? p.concepts as string[] : [],
relations: Array.isArray(p.relations) ? p.relations as Relation[] : [],
suggested_questions: Array.isArray(p.suggested_questions) ? p.suggested_questions as string[] : [],
tags: Array.isArray(p.tags) ? p.tags as string[] : [],
};
} catch {
const p = parseJsonSafe<Record<string, unknown> | null>(content, null);
if (!p) {
return { answer: content, concepts: [], relations: [], suggested_questions: [], tags: [] };
}
return {
answer: (p.answer as string) || "",
concepts: Array.isArray(p.concepts) ? (p.concepts as string[]) : [],
relations: Array.isArray(p.relations) ? (p.relations as Relation[]) : [],
suggested_questions: Array.isArray(p.suggested_questions)
? (p.suggested_questions as string[])
: [],
tags: Array.isArray(p.tags) ? (p.tags as string[]) : [],
};
}
}

View file

@ -1,5 +1,5 @@
import { requestUrl } from "obsidian";
import { DeepSeekSettings, DeepSeekResponse } from "../types";
import { LLMClient, parseJsonSafe } from "../core/llm";
const SYSTEM_PROMPT = `你是一个知识图谱构建助手。用户会向你提问,你需要:
1.
@ -18,38 +18,19 @@ const SYSTEM_PROMPT = `你是一个知识图谱构建助手。用户会向你提
}`;
export class DeepSeekClient {
constructor(private settings: DeepSeekSettings) {}
private llm: LLMClient;
constructor(settings: DeepSeekSettings) {
this.llm = new LLMClient(settings);
}
async ask(question: string): Promise<DeepSeekResponse> {
if (!this.settings.apiKey) {
throw new Error("请先在插件设置中配置 DeepSeek 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: "system", content: SYSTEM_PROMPT },
{ role: "user", content: question },
],
temperature: 0.7,
}),
throw: false,
const content = await this.llm.chat({
systemPrompt: SYSTEM_PROMPT,
userPrompt: question,
temperature: 0.7,
});
if (res.status !== 200) {
throw new Error(`DeepSeek API 错误: ${res.status} - ${res.text}`);
}
const data = res.json;
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error("DeepSeek 返回内容为空");
}
@ -58,26 +39,15 @@ export class DeepSeekClient {
}
private parseResponse(content: string): DeepSeekResponse {
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) ||
content.match(/(\{[\s\S]*\})/);
const jsonStr = jsonMatch ? jsonMatch[1] : content;
try {
const parsed = JSON.parse(jsonStr.trim()) as Record<string, unknown>;
return {
answer: (parsed.answer as string) || "",
concepts: Array.isArray(parsed.concepts) ? parsed.concepts as string[] : [],
relations: Array.isArray(parsed.relations) ? parsed.relations as DeepSeekResponse["relations"] : [],
tags: Array.isArray(parsed.tags) ? parsed.tags as string[] : [],
};
} catch {
return {
answer: content,
concepts: [],
relations: [],
tags: [],
};
const parsed = parseJsonSafe<Partial<DeepSeekResponse> | null>(content, null);
if (!parsed) {
return { answer: content, concepts: [], relations: [], tags: [] };
}
return {
answer: parsed.answer ?? "",
concepts: Array.isArray(parsed.concepts) ? parsed.concepts : [],
relations: Array.isArray(parsed.relations) ? parsed.relations : [],
tags: Array.isArray(parsed.tags) ? parsed.tags : [],
};
}
}

View file

@ -1,5 +1,5 @@
import { requestUrl } from "obsidian";
import { DeepSeekSettings } from "../types";
import { LLMClient, parseJsonSafe } from "../core/llm";
export type DiagramType =
| "auto"
@ -56,17 +56,17 @@ const PROMPT = `你是一个技术文档可视化助手。根据用户提供的
}`;
export class DiagramGenerator {
constructor(private settings: DeepSeekSettings) {}
private llm: LLMClient;
constructor(settings: DeepSeekSettings) {
this.llm = new LLMClient(settings);
}
async generate(
selection: string,
type: DiagramType = "auto",
surroundingContext?: string
): Promise<DiagramResult> {
if (!this.settings.apiKey) {
throw new Error("请先配置 API Key");
}
const contextSection = surroundingContext
? `当前文件的上下文(供参考):\n${surroundingContext}`
: "";
@ -76,39 +76,12 @@ export class DiagramGenerator {
.replace("{{context_section}}", contextSection)
.replace("{{type}}", type === "auto" ? "auto请自动判断最合适的类型" : type);
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.4,
}),
throw: false,
});
if (res.status !== 200) {
throw new Error(`API 错误: ${res.status} - ${res.text}`);
}
const data = res.json;
const content = data.choices?.[0]?.message?.content ?? "";
const content = await this.llm.chat({ userPrompt: prompt, temperature: 0.4 });
return this.parse(content);
}
/** 优化/扩展已有的图表代码 */
async refine(
existingCode: string,
instruction: string
): Promise<DiagramResult> {
if (!this.settings.apiKey) {
throw new Error("请先配置 API Key");
}
async refine(existingCode: string, instruction: string): Promise<DiagramResult> {
const prompt = `你是一个技术文档可视化助手。用户有一段已有的 Mermaid/LaTeX 代码,需要你根据指令进行优化或扩展。
@ -129,37 +102,13 @@ ${existingCode}
"explanation": "修改说明"
}`;
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.4,
}),
throw: false,
});
if (res.status !== 200) {
throw new Error(`API 错误: ${res.status} - ${res.text}`);
}
const data = res.json;
const content = data.choices?.[0]?.message?.content ?? "";
const content = await this.llm.chat({ userPrompt: prompt, temperature: 0.4 });
return this.parse(content);
}
private parse(content: string): DiagramResult {
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()) as Record<string, unknown>;
const p = parseJsonSafe<Record<string, unknown> | null>(content, null);
if (p) {
const type = (p.type as DiagramType) || "flowchart";
return {
type,
@ -167,14 +116,13 @@ ${existingCode}
code: (p.code as string) || "",
explanation: (p.explanation as string) || undefined,
};
} catch {
// 降级:尝试直接提取 mermaid 代码
const mermaidMatch = content.match(/```mermaid\s*([\s\S]*?)```/);
if (mermaidMatch) {
return { type: "flowchart", typeName: "流程图", code: mermaidMatch[1].trim() };
}
return { type: "flowchart", typeName: "流程图", code: content };
}
// 降级:尝试直接提取 mermaid 代码
const mermaidMatch = content.match(/```mermaid\s*([\s\S]*?)```/);
if (mermaidMatch) {
return { type: "flowchart", typeName: "流程图", code: mermaidMatch[1].trim() };
}
return { type: "flowchart", typeName: "流程图", code: content };
}
/** 将结果格式化为可插入笔记的 Markdown */

View file

@ -1,5 +1,5 @@
import { requestUrl } from "obsidian";
import { DeepSeekSettings, QuestionClassification } from "../types";
import { LLMClient, parseJsonSafe } from "../core/llm";
const CLASSIFY_PROMPT = `你是一个知识图谱助手,负责对用户的问题进行分类和关联。
@ -25,37 +25,30 @@ const CLASSIFY_PROMPT = `你是一个知识图谱助手,负责对用户的问
}`;
export class QuestionClassifier {
constructor(private settings: DeepSeekSettings) {}
private llm: LLMClient;
constructor(settings: DeepSeekSettings) {
this.llm = new LLMClient(settings);
}
async classify(question: string, history: string[]): Promise<QuestionClassification> {
if (!this.settings.apiKey) {
try {
this.llm.ensureApiKey();
} catch {
return this.defaultClassification();
}
const prompt = CLASSIFY_PROMPT
.replace("{{question}}", question)
.replace("{{history}}", history.length > 0 ? history.map((q, i) => `${i + 1}. ${q}`).join("\n") : "(无历史问题)");
.replace(
"{{history}}",
history.length > 0
? history.map((q, i) => `${i + 1}. ${q}`).join("\n")
: "(无历史问题)"
);
try {
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.3,
}),
throw: false,
});
if (res.status !== 200) throw new Error(`API ${res.status}`);
const data = res.json;
const content = data.choices?.[0]?.message?.content ?? "";
const content = await this.llm.chat({ userPrompt: prompt, temperature: 0.3 });
return this.parse(content);
} catch {
return this.defaultClassification();
@ -63,23 +56,21 @@ export class QuestionClassifier {
}
private parse(content: string): QuestionClassification {
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) ||
content.match(/(\{[\s\S]*\})/);
const jsonStr = jsonMatch ? jsonMatch[1] : content;
const p = parseJsonSafe<Record<string, unknown> | null>(content, null);
if (!p) return this.defaultClassification();
try {
const p = JSON.parse(jsonStr.trim()) as Record<string, unknown>;
return {
category: (["new", "refinement", "expansion"].includes(p.category as string) ? p.category : "new") as QuestionClassification["category"],
parent: typeof p.parent === "string" ? p.parent : null,
related: Array.isArray(p.related) ? p.related as string[] : [],
confidence: typeof p.confidence === "number" ? p.confidence : 0.5,
refinements: Array.isArray(p.refinements) ? p.refinements as string[] : [],
expansions: Array.isArray(p.expansions) ? p.expansions as string[] : [],
};
} catch {
return this.defaultClassification();
}
const category = ["new", "refinement", "expansion"].includes(p.category as string)
? (p.category as QuestionClassification["category"])
: "new";
return {
category,
parent: typeof p.parent === "string" ? p.parent : null,
related: Array.isArray(p.related) ? (p.related as string[]) : [],
confidence: typeof p.confidence === "number" ? p.confidence : 0.5,
refinements: Array.isArray(p.refinements) ? (p.refinements as string[]) : [],
expansions: Array.isArray(p.expansions) ? (p.expansions as string[]) : [],
};
}
private defaultClassification(): QuestionClassification {

View file

@ -1,5 +1,5 @@
import { requestUrl } from "obsidian";
import { DeepSeekSettings } from "../types";
import { LLMClient, extractJson } from "../core/llm";
export interface ReadingPlan {
bookTitle: string;
@ -133,11 +133,15 @@ const FEYNMAN_PROMPT = `你是一个学习检验助手。请基于章节内容
}`;
export class ReadingPlanner {
constructor(private settings: DeepSeekSettings) {}
private llm: LLMClient;
constructor(private settings: DeepSeekSettings) {
this.llm = new LLMClient(settings);
}
/** 第一步:生成全书骨架(轻量,一次请求) */
async planSkeleton(bookInfo: string, tableOfContents?: string): Promise<ReadingPlan> {
if (!this.settings.apiKey) throw new Error("请先配置 API Key");
this.llm.ensureApiKey();
const tocSection = tableOfContents
? `目录(用户提供):\n${tableOfContents}`
@ -213,23 +217,7 @@ export class ReadingPlanner {
}
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 ?? "";
return this.llm.chat({ userPrompt: prompt, temperature: 0.5 });
}
private parseSkeleton(raw: string): ReadingPlan {
@ -278,7 +266,6 @@ export class ReadingPlanner {
}
private extractJson(raw: string): string {
const match = raw.match(/```(?:json)?\s*([\s\S]*?)```/) || raw.match(/(\{[\s\S]*\})/);
return match ? match[1] : raw;
return extractJson(raw);
}
}

View file

@ -1,5 +1,6 @@
import { App, TFile, requestUrl } from "obsidian";
import { App, TFile } from "obsidian";
import { DeepSeekSettings } from "../types";
import { LLMClient, parseJsonSafe } from "../core/llm";
export interface SectionAppendResult {
items: string[]; // 新增条目列表
@ -23,7 +24,11 @@ const APPEND_PROMPT = `你是一个个人知识图谱助手。用户希望为概
}`;
export class SectionAppender {
constructor(private app: App, private settings: DeepSeekSettings) {}
private llm: LLMClient;
constructor(private app: App, private settings: DeepSeekSettings) {
this.llm = new LLMClient(settings);
}
/** 从文件内容中提取指定 section 的现有内容 */
extractSection(content: string, sectionName: string): { existing: string; startIndex: number; endIndex: number } | null {
@ -61,37 +66,13 @@ export class SectionAppender {
existingContent: string,
count = 3
): Promise<SectionAppendResult> {
if (!this.settings.apiKey) {
throw new Error("请先配置 API Key");
}
const prompt = APPEND_PROMPT
.replace("{{concept}}", conceptName)
.replace("{{section}}", sectionName)
.replace("{{existing}}", existingContent || "(暂无内容)")
.replace("{{count}}", String(count));
const 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.7,
}),
throw: false,
});
if (res.status !== 200) {
throw new Error(`API 错误: ${res.status} - ${res.text}`);
}
const data = res.json;
const raw = data.choices?.[0]?.message?.content ?? "";
const raw = await this.llm.chat({ userPrompt: prompt, temperature: 0.7 });
return this.parse(raw, sectionName);
}
@ -128,19 +109,14 @@ export class SectionAppender {
}
private parse(content: string, sectionName: string): SectionAppendResult {
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) ||
content.match(/(\{[\s\S]*\})/);
const jsonStr = jsonMatch ? jsonMatch[1] : content;
try {
const p = JSON.parse(jsonStr.trim()) as Record<string, unknown>;
const items: string[] = Array.isArray(p.items) ? p.items as string[] : [];
return { items, raw: this.formatItems(items, sectionName) };
} catch {
// 降级:把每行当作一个条目
const items = content.split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
const p = parseJsonSafe<Record<string, unknown> | null>(content, null);
if (p) {
const items: string[] = Array.isArray(p.items) ? (p.items as string[]) : [];
return { items, raw: this.formatItems(items, sectionName) };
}
// 降级:把每行当作一个条目
const items = content.split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
return { items, raw: this.formatItems(items, sectionName) };
}
private escapeRegex(str: string): string {

View file

@ -1,5 +1,5 @@
import { requestUrl } from "obsidian";
import { DeepSeekSettings } from "../types";
import { LLMClient, parseJsonSafe } from "../core/llm";
export type CompletionMode =
| "concept" // 概念页补全
@ -77,7 +77,11 @@ export interface DocumentSuggestion {
}
export class SmartCompleter {
constructor(private settings: DeepSeekSettings) {}
private llm: LLMClient;
constructor(settings: DeepSeekSettings) {
this.llm = new LLMClient(settings);
}
/** 补全空 section */
async completeSection(
@ -106,60 +110,26 @@ export class SmartCompleter {
/** 续写 */
async continueWriting(beforeCursor: string): Promise<SmartCompletionResult> {
const prompt = CONTINUE_PROMPT
.replace("{{before}}", beforeCursor.slice(-1500));
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 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 ?? "";
return this.llm.chat({ userPrompt: prompt, temperature: 0.6 });
}
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 [];
}
const p = parseJsonSafe<Record<string, unknown> | null>(raw, null);
if (!p) return [];
const suggestions = p.suggestions;
return Array.isArray(suggestions) ? (suggestions as DocumentSuggestion[]) : [];
}
}

View file

@ -0,0 +1,80 @@
import { ArtifactBuildParams, ARTIFACT_TYPE_LABELS, USAGE_MODE_LABELS, SOURCE_SCOPE_LABELS, EVIDENCE_POLICY_LABELS } from "./types";
const SYSTEM_PROMPT = `你是一个知识到执行资产的结构化转换助手。
- checklist
- routine
- sop
- plan
- review
- question-list
- decision
- custom
1.
2. sourceLinks使 Obsidian
3. inferred: true
4. riskLevel: "watch" "high"
5.
6. sourceLinkssourceLinks "来源内容" path|alias [[ ]]
"Reading/Book/Chapter1.md|第1章"
"[[Reading/Book/Chapter1.md|第1章]]"
7. id 使 "item-1", "item-2"
8. JSON
{
"title": "资产标题",
"artifactType": "checklist",
"usageMode": "recurring",
"target": "对象",
"frequency": "daily",
"sourceLinks": ["来源路径"],
"items": [
{
"id": "item-1",
"title": "条目标题",
"description": "可选说明",
"category": "分类",
"required": true,
"sourceLinks": ["具体来源"],
"inferred": false,
"recordFields": [
{ "name": "记录", "type": "text" },
{ "name": "异常", "type": "text" }
],
"riskLevel": "normal"
}
]
}`;
/**
* Builds prompts for artifact generation.
*/
export class ArtifactPromptBuilder {
buildSystemPrompt(): string {
return SYSTEM_PROMPT;
}
buildUserPrompt(params: ArtifactBuildParams, context: string): string {
const parts = [
`用户目标:${params.target || "未指定"}`,
`执行资产类型:${ARTIFACT_TYPE_LABELS[params.artifactType]}${params.artifactType}`,
`使用方式:${USAGE_MODE_LABELS[params.usageMode]}${params.usageMode}`,
`来源范围:${SOURCE_SCOPE_LABELS[params.sourceScope]}${params.sourceScope}`,
`依据要求:${EVIDENCE_POLICY_LABELS[params.evidencePolicy]}${params.evidencePolicy}`,
];
if (params.instruction) {
parts.push(`\n用户补充指令${params.instruction}`);
}
parts.push(`\n来源内容\n${context}`);
return parts.join("\n");
}
}

View file

@ -0,0 +1,163 @@
import { ExecutionArtifact, ArtifactItem, ARTIFACT_TYPE_LABELS, USAGE_MODE_LABELS } from "./types";
import { SCHEMA_VERSION, todayIso } from "../schema";
/**
* Renders an ExecutionArtifact into Markdown for template or run files.
*/
export class ArtifactRenderer {
/** Render as a reusable template note. */
renderTemplate(artifact: ExecutionArtifact): string {
const fm = this.buildFrontmatter(artifact, "template");
const body = this.buildBody(artifact, false);
return `${fm}\n${body}`;
}
/** Render as a concrete execution run for a given date. */
renderRun(artifact: ExecutionArtifact, date: string): string {
const fm = this.buildRunFrontmatter(artifact, date);
const body = this.buildBody(artifact, true);
const context = this.buildRunContext(artifact);
return `${fm}\n${context}\n${body}\n${this.buildRunFooter()}`;
}
private buildFrontmatter(artifact: ExecutionArtifact, docType: "template" | "run"): string {
const lines = [
"---",
`type: execution-artifact-${docType}`,
`schema_version: ${SCHEMA_VERSION}`,
`artifact_type: ${artifact.artifactType}`,
`title: ${this.yamlStr(artifact.title)}`,
`usage_mode: ${artifact.usageMode}`,
`source_scope: ${artifact.sourceScope}`,
`evidence_policy: ${artifact.evidencePolicy}`,
];
if (artifact.target) lines.push(`target: ${this.yamlStr(artifact.target)}`);
if (artifact.frequency) lines.push(`frequency: ${artifact.frequency}`);
lines.push(`status: draft`);
lines.push(`created_at: ${todayIso()}`);
lines.push("---");
return lines.join("\n");
}
private buildRunFrontmatter(artifact: ExecutionArtifact, date: string): string {
const lines = [
"---",
`type: execution-artifact-run`,
`schema_version: ${SCHEMA_VERSION}`,
`artifact_type: ${artifact.artifactType}`,
`template: ${this.yamlStr(artifact.title)}`,
`date: ${date}`,
`status: open`,
"---",
];
return lines.join("\n");
}
private buildBody(artifact: ExecutionArtifact, isRun: boolean): string {
const title = isRun
? `# ${todayIso()} ${artifact.title}`
: `# ${artifact.title}`;
const meta = [
`> [!info] ${isRun ? "今日执行" : "执行资产模板"}`,
`> 类型:${ARTIFACT_TYPE_LABELS[artifact.artifactType]}`,
`> 使用方式:${USAGE_MODE_LABELS[artifact.usageMode]}`,
artifact.target ? `> 对象:${artifact.target}` : null,
artifact.frequency ? `> 频率:${artifact.frequency}` : null,
artifact.sourceLinks.length > 0
? `> 来源:${artifact.sourceLinks.map((l) => this.wikiLink(l)).join("、")}`
: null,
].filter(Boolean).join("\n");
// Safety disclaimer for sensitive domains
const disclaimer = this.needsSafetyDisclaimer(artifact)
? `\n> [!warning] 使用边界\n> 该执行资产用于记录、观察和复盘,不构成医学、法律或投资建议。出现异常情况请咨询专业人士。\n`
: "";
// Group items by category
const grouped = this.groupByCategory(artifact.items);
const sections: string[] = [];
for (const [category, items] of grouped) {
sections.push(`\n### ${category}\n`);
for (const item of items) {
sections.push(this.renderItem(item, isRun));
}
}
// Inferred items warning
const inferredItems = artifact.items.filter((i) => i.inferred);
let inferredSection = "";
if (inferredItems.length > 0 && !isRun) {
inferredSection = `\n## 未验证项\n\n> [!warning]\n> 以下 ${inferredItems.length} 个条目由 AI 推断生成,未找到明确来源,建议人工确认。\n\n${inferredItems.map((i) => `- ${i.title}`).join("\n")}\n`;
}
return `${title}\n\n${meta}${disclaimer}\n${sections.join("\n")}${inferredSection}`;
}
private renderItem(item: ArtifactItem, isRun: boolean): string {
const lines: string[] = [];
const checkbox = isRun ? "- [ ]" : "-";
const riskMark = item.riskLevel === "high" ? " ⚠️" : item.riskLevel === "watch" ? " 👀" : "";
lines.push(`${checkbox} ${item.title}${riskMark}`);
if (item.sourceLinks.length > 0) {
lines.push(` - 依据:${item.sourceLinks.map((l) => this.wikiLink(l)).join("、")}`);
} else if (item.inferred) {
lines.push(` - 依据:*AI 推断,建议确认*`);
}
if (isRun && item.recordFields) {
for (const field of item.recordFields) {
lines.push(` - ${field.name}`);
}
}
return lines.join("\n");
}
private buildRunContext(artifact: ExecutionArtifact): string {
return `\n## 今日上下文\n\n- 对象:${artifact.target ?? ""}\n- 特殊情况:\n`;
}
private buildRunFooter(): string {
return `\n## 今日问题\n\n- \n\n## 明日调整\n\n- \n`;
}
/** Safely encode a string for YAML (uses JSON quoting). */
private yamlStr(value: string): string {
return JSON.stringify(value);
}
private needsSafetyDisclaimer(artifact: ExecutionArtifact): boolean {
const sensitiveKeywords = [
"婴儿", "母亲", "健康", "医疗", "诊断", "症状", "用药", "治疗",
"法律", "合同", "诉讼", "投资", "理财", "基金", "股票",
"baby", "infant", "health", "medical", "diagnosis", "legal", "investment",
];
const text = `${artifact.title} ${artifact.target ?? ""}`.toLowerCase();
const hasHighRisk = artifact.items.some((i) => i.riskLevel === "high" || i.riskLevel === "watch");
const hasSensitiveKeyword = sensitiveKeywords.some((k) => text.includes(k));
return hasHighRisk || hasSensitiveKeyword;
}
private groupByCategory(items: ArtifactItem[]): Map<string, ArtifactItem[]> {
const map = new Map<string, ArtifactItem[]>();
for (const item of items) {
const cat = item.category || "通用";
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(item);
}
return map;
}
/** Ensure a link string becomes a proper [[wikilink]] without double-wrapping. */
private wikiLink(link: string): string {
const trimmed = link.trim();
if (trimmed.startsWith("[[") && trimmed.endsWith("]]")) return trimmed;
// Strip accidental [[ ]] if partially wrapped
const cleaned = trimmed.replace(/^\[\[/, "").replace(/\]\]$/, "");
return `[[${cleaned}]]`;
}
}

View file

@ -0,0 +1,76 @@
import { ExecutionArtifact, ArtifactItem } from "./types";
export interface ValidationResult {
valid: boolean;
warnings: string[];
stats: {
totalItems: number;
withSource: number;
inferred: number;
highRisk: number;
};
}
/**
* Validates a parsed ExecutionArtifact for completeness and safety.
*/
export class ArtifactValidator {
validate(artifact: ExecutionArtifact): ValidationResult {
const warnings: string[] = [];
const items = artifact.items ?? [];
if (!artifact.title) warnings.push("缺少标题");
if (items.length === 0) warnings.push("没有生成任何条目");
const withSource = items.filter((i) => i.sourceLinks && i.sourceLinks.length > 0).length;
const inferred = items.filter((i) => i.inferred).length;
const highRisk = items.filter((i) => i.riskLevel === "high").length;
// Evidence policy enforcement
if (artifact.evidencePolicy === "strict" && inferred > 0) {
warnings.push(`严格来源模式下有 ${inferred} 个推断条目,建议确认或移除`);
}
if (highRisk > 0) {
warnings.push(`${highRisk} 个条目标为高风险,请仔细审阅`);
}
return {
valid: items.length > 0 && !!artifact.title,
warnings,
stats: {
totalItems: items.length,
withSource,
inferred,
highRisk,
},
};
}
/** Normalize an AI-parsed artifact: ensure IDs, defaults. */
normalize(raw: Partial<ExecutionArtifact>): ExecutionArtifact {
const items: ArtifactItem[] = (raw.items ?? []).map((item, i) => ({
id: item.id || `item-${i + 1}`,
title: item.title || `条目 ${i + 1}`,
description: item.description,
category: item.category,
required: item.required ?? true,
sourceLinks: item.sourceLinks ?? [],
inferred: item.inferred ?? false,
recordFields: item.recordFields,
riskLevel: item.riskLevel ?? "normal",
}));
return {
title: raw.title || "未命名执行资产",
artifactType: raw.artifactType || "checklist",
usageMode: raw.usageMode || "one-off",
sourceScope: raw.sourceScope || "freeform",
evidencePolicy: raw.evidencePolicy || "balanced",
target: raw.target,
frequency: raw.frequency,
sourceLinks: raw.sourceLinks ?? [],
items,
};
}
}

View file

@ -0,0 +1,19 @@
export { ArtifactPromptBuilder } from "./ArtifactPromptBuilder";
export { ArtifactRenderer } from "./ArtifactRenderer";
export { ArtifactValidator } from "./ArtifactValidator";
export type {
ArtifactType,
ArtifactUsageMode,
ArtifactSourceScope,
EvidencePolicy,
ArtifactBuildParams,
ArtifactItem,
ArtifactRecordField,
ExecutionArtifact,
} from "./types";
export {
ARTIFACT_TYPE_LABELS,
USAGE_MODE_LABELS,
SOURCE_SCOPE_LABELS,
EVIDENCE_POLICY_LABELS,
} from "./types";

106
src/core/artifact/types.ts Normal file
View file

@ -0,0 +1,106 @@
/**
* Execution Artifact the universal "knowledge-to-action" output.
*
* An artifact is a structured, reusable, trackable deliverable generated
* from knowledge sources. It can be a checklist, routine, SOP, plan,
* review template, question list, decision record, or custom format.
*/
export type ArtifactType =
| "checklist"
| "routine"
| "sop"
| "plan"
| "review"
| "question-list"
| "decision"
| "custom";
export type ArtifactUsageMode =
| "one-off"
| "template"
| "recurring";
export type ArtifactSourceScope =
| "selection"
| "current-note"
| "reading-project"
| "related-vault"
| "freeform";
export type EvidencePolicy =
| "strict"
| "balanced"
| "freeform";
export interface ArtifactRecordField {
name: string;
type: "text" | "number" | "boolean" | "choice";
options?: string[];
}
export interface ArtifactItem {
id: string;
title: string;
description?: string;
category?: string;
required?: boolean;
sourceLinks: string[];
inferred?: boolean;
recordFields?: ArtifactRecordField[];
riskLevel?: "normal" | "watch" | "high";
}
export interface ExecutionArtifact {
title: string;
artifactType: ArtifactType;
usageMode: ArtifactUsageMode;
sourceScope: ArtifactSourceScope;
evidencePolicy: EvidencePolicy;
target?: string;
frequency?: string;
sourceLinks: string[];
items: ArtifactItem[];
}
/** Parameters collected from the user via Builder modal or natural language. */
export interface ArtifactBuildParams {
artifactType: ArtifactType;
target: string;
usageMode: ArtifactUsageMode;
sourceScope: ArtifactSourceScope;
evidencePolicy: EvidencePolicy;
/** Additional context / instruction from the user. */
instruction?: string;
}
export const ARTIFACT_TYPE_LABELS: Record<ArtifactType, string> = {
checklist: "检查表",
routine: "例行流程",
sop: "标准操作流程",
plan: "执行计划",
review: "复盘表",
"question-list": "问题清单",
decision: "决策记录",
custom: "自定义",
};
export const USAGE_MODE_LABELS: Record<ArtifactUsageMode, string> = {
"one-off": "一次性",
template: "可复用模板",
recurring: "每日/每周例行",
};
export const SOURCE_SCOPE_LABELS: Record<ArtifactSourceScope, string> = {
selection: "当前选中内容",
"current-note": "当前笔记",
"reading-project": "当前阅读项目",
"related-vault": "相关知识库",
freeform: "自由草稿",
};
export const EVIDENCE_POLICY_LABELS: Record<EvidencePolicy, string> = {
strict: "严格依据来源",
balanced: "允许少量推断",
freeform: "自由生成草稿",
};

View file

@ -0,0 +1,132 @@
import { ExecutionPlan, VaultOperation, RiskLevel, PlanSource } from "./types";
/**
* Fluent builder for constructing an ExecutionPlan.
*
* Usage:
* ```
* const plan = new PlanBuilder("从会议纪要生成行动计划", "assistant")
* .createFile("Projects/xxx.md", content)
* .appendSection("Meeting/2024-01-01.md", "执行计划", link)
* .build();
* ```
*/
export class PlanBuilder {
private ops: VaultOperation[] = [];
constructor(
private title: string,
private source: PlanSource
) {}
createFile(path: string, content: string): this {
this.ops.push({ type: "create-file", path, content });
return this;
}
modifyFile(path: string, content: string, description?: string): this {
this.ops.push({ type: "modify-file", path, content, description });
return this;
}
appendSection(path: string, section: string, content: string): this {
this.ops.push({ type: "append-section", path, section, content });
return this;
}
replaceSelection(path: string, oldText: string, newText: string): this {
this.ops.push({ type: "replace-selection", path, oldText, newText });
return this;
}
moveFile(from: string, to: string): this {
this.ops.push({ type: "move-file", from, to });
return this;
}
createLink(path: string, target: string, location = "end"): this {
this.ops.push({ type: "create-link", path, target, location });
return this;
}
updateFrontmatter(path: string, fields: Record<string, unknown>): this {
this.ops.push({ type: "update-frontmatter", path, fields });
return this;
}
build(): ExecutionPlan {
return {
id: this.generateId(),
title: this.title,
source: this.source,
operations: this.ops,
previewMarkdown: this.renderPreview(),
riskLevel: this.assessRisk(),
createdAt: new Date().toISOString(),
};
}
// ── Internals ──────────────────────────────────────────────
private generateId(): string {
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 6);
return `exec-${ts}-${rand}`;
}
private assessRisk(): RiskLevel {
const fileCount = new Set(this.ops.map((op) => this.getPath(op))).size;
const hasMoves = this.ops.some((op) => op.type === "move-file");
const hasModifies = this.ops.some((op) => op.type === "modify-file");
if (fileCount >= 5 || hasMoves) return "high";
if (fileCount >= 3 || hasModifies) return "medium";
return "low";
}
private renderPreview(): string {
const lines: string[] = [`## 执行计划:${this.title}\n`];
const grouped = this.groupByPath();
for (const [path, ops] of grouped) {
lines.push(`### \`${path}\``);
for (const op of ops) {
lines.push(`- ${this.describeOp(op)}`);
}
lines.push("");
}
lines.push(`---`);
lines.push(`影响文件:${grouped.size} 个 | 操作数:${this.ops.length} | 风险等级:${this.assessRisk()}`);
return lines.join("\n");
}
private groupByPath(): Map<string, VaultOperation[]> {
const map = new Map<string, VaultOperation[]>();
for (const op of this.ops) {
const key = this.getPath(op);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(op);
}
return map;
}
private getPath(op: VaultOperation): string {
switch (op.type) {
case "move-file": return op.from;
default: return (op as { path: string }).path;
}
}
private describeOp(op: VaultOperation): string {
switch (op.type) {
case "create-file": return `创建文件`;
case "modify-file": return op.description ?? `修改文件内容`;
case "append-section": return `追加到 §${op.section}`;
case "replace-selection": return `替换选中文本`;
case "move-file": return `移动到 \`${op.to}\``;
case "create-link": return `添加链接 → [[${op.target}]]`;
case "update-frontmatter": return `更新 frontmatter: ${Object.keys(op.fields).join(", ")}`;
}
}
}

View file

@ -0,0 +1,144 @@
import { App, TFile, normalizePath } from "obsidian";
import { ExecutionPlan } from "./types";
import { SCHEMA_VERSION, todayIso } from "../schema";
/**
* PlanDraftStore persists an ExecutionPlan as a "pending" draft note
* and stores the raw plan data separately for programmatic recovery.
*
* The draft note is human-readable. The JSON is stored in plugin data
* (not in the note) so users don't see raw technical payload.
*
* Execution flow:
* 1. persistDraft(plan) saves note + stores plan in memory/plugin data
* 2. User reviews the note
* 3. User triggers "确认执行此计划" loadPlan(planId) PlanExecutor.execute()
* 4. Draft note status updated to "executed" or deleted
*/
export class PlanDraftStore {
private folder = "Knowledge/_ExecutionPlans";
/** In-memory plan cache, keyed by plan_id. */
private planCache: Map<string, ExecutionPlan> = new Map();
constructor(private app: App) {}
/** Save a plan draft note and cache the raw plan for later execution. */
async persistDraft(plan: ExecutionPlan): Promise<TFile> {
const folder = normalizePath(this.folder);
await this.ensureFolder(folder);
const safeName = plan.title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 40);
let path = normalizePath(`${folder}/${todayIso()}-${safeName}.md`);
let suffix = 2;
while (this.app.vault.getAbstractFileByPath(path)) {
path = normalizePath(`${folder}/${todayIso()}-${safeName}-${suffix}.md`);
suffix++;
}
const content = this.renderDraft(plan);
const file = await this.app.vault.create(path, content);
// Cache plan for later execution
this.planCache.set(plan.id, plan);
return file;
}
/** Retrieve a cached plan by ID (for "confirm and execute" flow). */
getPlan(planId: string): ExecutionPlan | undefined {
return this.planCache.get(planId);
}
/** Mark a draft as executed by updating its frontmatter status. */
async markExecuted(file: TFile): Promise<void> {
let content = await this.app.vault.read(file);
content = content.replace("status: pending", "status: executed");
content = content.replace(
"> [!warning] 此计划尚未执行\n> 请审阅后在命令面板中选择「确认执行此计划」,或删除此文件取消。",
"> [!success] 此计划已执行\n> 执行记录已保存到 Knowledge/_Executions/"
);
await this.app.vault.modify(file, content);
}
/** List all pending plan files. */
getPendingPlans(): TFile[] {
const folder = normalizePath(this.folder);
return this.app.vault.getMarkdownFiles()
.filter((f) => f.path.startsWith(folder + "/"))
.sort((a, b) => b.stat.mtime - a.stat.mtime);
}
private renderDraft(plan: ExecutionPlan): string {
const riskLabel = plan.riskLevel === "high" ? "🔴 高风险"
: plan.riskLevel === "medium" ? "🟡 中风险"
: "🟢 低风险";
const ops = plan.operations.map((op, i) => {
const desc = this.describeOp(op);
return `${i + 1}. ${desc}`;
}).join("\n");
const affectedFiles = [...new Set(plan.operations.map((op) => {
return "path" in op ? (op as { path: string }).path : (op as { from: string }).from;
}))];
const fileList = affectedFiles.map((f) => `- \`${f}\``).join("\n");
return `---
type: execution-plan
schema_version: ${SCHEMA_VERSION}
status: pending
plan_id: ${plan.id}
source: ${plan.source}
risk_level: ${plan.riskLevel}
operations_count: ${plan.operations.length}
created_at: ${plan.createdAt}
---
# 📋 ${plan.title}
> [!warning]
>
##
| | |
| --- | --- |
| | ${plan.source} |
| | ${riskLabel} |
| | ${plan.operations.length} |
| | ${affectedFiles.length} |
| | ${plan.createdAt} |
##
${ops}
##
${fileList}
##
${plan.previewMarkdown}
`;
}
private describeOp(op: ExecutionPlan["operations"][number]): string {
switch (op.type) {
case "create-file": return `创建文件 \`${op.path}\``;
case "modify-file": return `修改文件 \`${op.path}\`${op.description ? `${op.description}` : ""}`;
case "append-section": return `追加到 \`${op.path}\` 的 §${op.section}`;
case "replace-selection": return `替换 \`${op.path}\` 中的文本`;
case "move-file": return `移动 \`${op.from}\`\`${op.to}\``;
case "create-link": return `\`${op.path}\` 中添加链接 → \`${op.target}\``;
case "update-frontmatter": return `更新 \`${op.path}\` 的 frontmatter${Object.keys(op.fields).join(", ")}`;
}
}
private async ensureFolder(path: string): Promise<void> {
if (!this.app.vault.getAbstractFileByPath(path)) {
await this.app.vault.createFolder(path);
}
}
}

View file

@ -0,0 +1,219 @@
import { App, TFile, normalizePath, parseYaml, stringifyYaml } from "obsidian";
import { ExecutionPlan, ExecutionRecord, VaultOperation } from "./types";
import { SCHEMA_VERSION, todayIso } from "../schema";
/**
* PlanExecutor applies an ExecutionPlan to the vault and records the result.
*
* Current scope (v1):
* - Executes operations sequentially.
* - Records the result as a markdown note in `Knowledge/_Executions/`.
* - Does NOT support rollback yet (planned for v3).
*/
export class PlanExecutor {
constructor(private app: App) {}
/**
* Apply all operations in a plan.
* Returns an ExecutionRecord with the outcome.
*/
async execute(plan: ExecutionPlan): Promise<ExecutionRecord> {
const affectedPaths: string[] = [];
let error: string | undefined;
try {
for (const op of plan.operations) {
await this.applyOp(op);
affectedPaths.push(this.getPath(op));
}
} catch (err) {
error = (err as Error).message;
}
const record: ExecutionRecord = {
plan,
executedAt: new Date().toISOString(),
success: !error,
affectedPaths: [...new Set(affectedPaths)],
error,
};
await this.persistRecord(record);
return record;
}
// ── Apply individual operations ────────────────────────────
private async applyOp(op: VaultOperation): Promise<void> {
switch (op.type) {
case "create-file":
await this.ensureParentFolder(op.path);
await this.app.vault.create(op.path, op.content);
break;
case "modify-file": {
const file = this.getFile(op.path);
await this.app.vault.modify(file, op.content);
break;
}
case "append-section": {
const file = this.getFile(op.path);
const content = await this.app.vault.read(file);
const updated = this.appendToSection(content, op.section, op.content);
await this.app.vault.modify(file, updated);
break;
}
case "replace-selection": {
const file = this.getFile(op.path);
const content = await this.app.vault.read(file);
const updated = content.replace(op.oldText, op.newText);
await this.app.vault.modify(file, updated);
break;
}
case "move-file": {
const file = this.getFile(op.from);
await this.ensureParentFolder(op.to);
await this.app.vault.rename(file, op.to);
break;
}
case "create-link": {
const file = this.getFile(op.path);
const content = await this.app.vault.read(file);
const link = `[[${op.target}]]`;
if (content.includes(link)) break; // no duplicates
if (op.location === "end") {
await this.app.vault.modify(file, content.trimEnd() + `\n- ${link}\n`);
} else {
// Append under section heading
const updated = this.appendToSection(content, op.location, `- ${link}`);
await this.app.vault.modify(file, updated);
}
break;
}
case "update-frontmatter": {
const file = this.getFile(op.path);
const content = await this.app.vault.read(file);
const updated = this.patchFrontmatter(content, op.fields);
await this.app.vault.modify(file, updated);
break;
}
}
}
// ── Helpers ────────────────────────────────────────────────
private getFile(path: string): TFile {
const f = this.app.vault.getAbstractFileByPath(path);
if (!f || !(f instanceof TFile)) {
throw new Error(`文件不存在:${path}`);
}
return f;
}
private async ensureParentFolder(filePath: string): Promise<void> {
const parts = filePath.split("/");
parts.pop(); // remove filename
if (parts.length === 0) return;
const folder = normalizePath(parts.join("/"));
if (!this.app.vault.getAbstractFileByPath(folder)) {
await this.app.vault.createFolder(folder);
}
}
private appendToSection(content: string, sectionName: string, text: string): string {
const regex = new RegExp(`(^##\\s+${this.escapeRegex(sectionName)}\\s*\\n)`, "m");
const match = content.match(regex);
if (match && match.index !== undefined) {
const insertPos = match.index + match[0].length;
return content.slice(0, insertPos) + text + "\n" + content.slice(insertPos);
}
// Section not found — append at end
return content.trimEnd() + `\n\n## ${sectionName}\n${text}\n`;
}
private patchFrontmatter(content: string, fields: Record<string, unknown>): string {
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!fmMatch) {
// No frontmatter — create one
const fm = stringifyYaml(fields);
return `---\n${fm}---\n\n${content}`;
}
try {
const existing = parseYaml(fmMatch[1]) as Record<string, unknown> ?? {};
const merged = { ...existing, ...fields };
return `---\n${stringifyYaml(merged)}---\n\n${fmMatch[2]}`;
} catch {
return content;
}
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// ── Persist execution log ──────────────────────────────────
private async persistRecord(record: ExecutionRecord): Promise<void> {
const folder = normalizePath("Knowledge/_Executions");
if (!this.app.vault.getAbstractFileByPath(folder)) {
await this.app.vault.createFolder(folder);
}
const date = todayIso();
const safeName = record.plan.title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 40);
const fileName = `${date}-${safeName}.md`;
let path = normalizePath(`${folder}/${fileName}`);
// Conflict-safe
let suffix = 2;
while (this.app.vault.getAbstractFileByPath(path)) {
path = normalizePath(`${folder}/${date}-${safeName}-${suffix}.md`);
suffix++;
}
const content = this.renderRecord(record);
await this.app.vault.create(path, content);
}
private renderRecord(record: ExecutionRecord): string {
const { plan } = record;
const statusEmoji = record.success ? "✅" : "❌";
return `---
type: execution
schema_version: ${SCHEMA_VERSION}
status: ${record.success ? "done" : "failed"}
source: ${plan.source}
plan_id: ${plan.id}
risk_level: ${plan.riskLevel}
operations_count: ${plan.operations.length}
affected_files: ${record.affectedPaths.length}
executed_at: ${record.executedAt}
created_at: ${plan.createdAt}
---
# ${statusEmoji} ${plan.title}
##
${plan.previewMarkdown}
##
${record.affectedPaths.map((p) => `- [[${p}]]`).join("\n")}
${record.error ? `\n## 错误\n\n\`\`\`\n${record.error}\n\`\`\`\n` : ""}
`;
}
private getPath(op: VaultOperation): string {
switch (op.type) {
case "move-file": return op.from;
default: return (op as { path: string }).path;
}
}
}

View file

@ -0,0 +1,17 @@
export { PlanBuilder } from "./PlanBuilder";
export { PlanExecutor } from "./PlanExecutor";
export { PlanDraftStore } from "./PlanDraftStore";
export type {
ExecutionPlan,
ExecutionRecord,
VaultOperation,
CreateFileOp,
ModifyFileOp,
AppendSectionOp,
ReplaceSelectionOp,
MoveFileOp,
CreateLinkOp,
UpdateFrontmatterOp,
RiskLevel,
PlanSource,
} from "./types";

115
src/core/execution/types.ts Normal file
View file

@ -0,0 +1,115 @@
/**
* ExecutionPlan the core data structure for the "knowledge → action" pipeline.
*
* Every vault-modifying batch that the plugin performs (from AI or user request)
* should first produce an ExecutionPlan, present a preview to the user, and only
* apply changes after confirmation.
*
* Future: add rollback, dry-run, and diff rendering.
*/
/** A single atomic vault operation. */
export type VaultOperation =
| CreateFileOp
| ModifyFileOp
| AppendSectionOp
| ReplaceSelectionOp
| MoveFileOp
| CreateLinkOp
| UpdateFrontmatterOp;
export interface CreateFileOp {
type: "create-file";
path: string;
content: string;
}
export interface ModifyFileOp {
type: "modify-file";
path: string;
/** The full new content. */
content: string;
/** Optional description of what changed. */
description?: string;
}
export interface AppendSectionOp {
type: "append-section";
path: string;
section: string;
content: string;
}
export interface ReplaceSelectionOp {
type: "replace-selection";
path: string;
/** The text to be replaced (used for display; actual replacement is cursor-based). */
oldText: string;
newText: string;
}
export interface MoveFileOp {
type: "move-file";
from: string;
to: string;
}
export interface CreateLinkOp {
type: "create-link";
/** The file where the link is added. */
path: string;
/** The target to link to. */
target: string;
/** Where in the file to add (section heading, or "end"). */
location: string;
}
export interface UpdateFrontmatterOp {
type: "update-frontmatter";
path: string;
/** Fields to set or overwrite. */
fields: Record<string, unknown>;
}
/** Risk level — determined by how many files are touched and the nature of ops. */
export type RiskLevel = "low" | "medium" | "high";
/** Source feature that generated this plan. */
export type PlanSource =
| "assistant"
| "reading"
| "question"
| "concept"
| "sync"
| "beautify"
| "scheduler"
| "artifact"
| "manual";
/**
* An ExecutionPlan groups one or more VaultOperations that logically belong
* together and should be previewed/confirmed as a unit.
*/
export interface ExecutionPlan {
id: string;
title: string;
source: PlanSource;
operations: VaultOperation[];
/** Markdown preview shown to the user before apply. */
previewMarkdown: string;
riskLevel: RiskLevel;
createdAt: string; // ISO datetime
}
/**
* Record of a completed execution, persisted as a note
* in `Knowledge/_Executions/`.
*/
export interface ExecutionRecord {
plan: ExecutionPlan;
executedAt: string;
success: boolean;
/** Paths that were actually modified (subset of plan if partial failure). */
affectedPaths: string[];
error?: string;
}

View file

@ -0,0 +1,298 @@
import { App, TFile, CachedMetadata, normalizePath } from "obsidian";
/**
* A single indexed vault entry.
* Derived from Obsidian's metadataCache + file stats no embedding, no external DB.
*/
export interface IndexEntry {
path: string;
basename: string;
title: string; // first H1 or basename
type: string | undefined; // frontmatter.type
domain: string | undefined; // frontmatter.domain
status: string | undefined; // frontmatter.status
tags: string[];
headings: string[]; // all ## headings
links: string[]; // outgoing [[links]]
backlinks: string[]; // populated after full scan
concepts: string[]; // frontmatter.concepts (for questions)
mtime: number;
}
export interface SearchResult {
entry: IndexEntry;
score: number;
/** Which matching criteria contributed. */
matchedOn: ("title" | "tag" | "heading" | "link" | "backlink" | "concept" | "domain" | "keyword")[];
}
export interface SearchOptions {
/** Max results to return. Default 10. */
limit?: number;
/** Filter by frontmatter type. */
types?: string[];
/** Filter by domain. */
domains?: string[];
/** Boost entries that link to or are linked from this file path. */
contextFile?: string;
}
/**
* KnowledgeIndexService lightweight in-memory index built from metadataCache.
*
* Design:
* - No embedding, no external dependency.
* - Built once on load, updated incrementally via metadataCache events.
* - Three-layer scoring: exact match structural (links/backlinks/domain) keyword.
*
* Usage:
* ```
* const idx = new KnowledgeIndexService(app);
* idx.rebuild();
* const results = idx.search("并发控制");
* ```
*/
export class KnowledgeIndexService {
private entries: Map<string, IndexEntry> = new Map();
constructor(private app: App) {}
// ── Build / Rebuild ────────────────────────────────────────
/** Full rebuild. Call once on plugin load. */
rebuild(): void {
this.entries.clear();
const files = this.app.vault.getMarkdownFiles();
for (const file of files) {
this.indexFile(file);
}
this.computeBacklinks();
}
/** Incremental update for a single file. */
updateFile(file: TFile): void {
this.indexFile(file);
// Recompute backlinks (cheap for incremental — just re-scan outgoing of this file)
this.computeBacklinks();
}
/** Remove a file from the index. */
removeFile(path: string): void {
this.entries.delete(path);
// Clean backlinks pointing to this file
for (const entry of this.entries.values()) {
entry.backlinks = entry.backlinks.filter((b) => b !== path);
}
}
// ── Search ─────────────────────────────────────────────────
/**
* Search the index for entries relevant to the given query.
*
* Scoring layers:
* 1. Exact title/basename match (score += 10)
* 2. Tag match (score += 5)
* 3. Heading match (score += 3)
* 4. Link/backlink match (score += 4)
* 5. Domain match (score += 3)
* 6. Concept list match (score += 4)
* 7. Keyword substring in title/headings (score += 2)
*
* If `contextFile` is provided, entries linked from/to it get +3 boost.
*/
search(query: string, options: SearchOptions = {}): SearchResult[] {
const { limit = 10, types, domains, contextFile } = options;
const terms = this.tokenize(query);
if (terms.length === 0) return [];
const contextLinks = contextFile ? this.getLinksOf(contextFile) : new Set<string>();
const results: SearchResult[] = [];
for (const entry of this.entries.values()) {
// Filters
if (types && types.length > 0 && (!entry.type || !types.includes(entry.type))) continue;
if (domains && domains.length > 0 && (!entry.domain || !domains.includes(entry.domain))) continue;
let score = 0;
const matchedOn: SearchResult["matchedOn"] = [];
for (const term of terms) {
const lower = term.toLowerCase();
// 1. Title / basename
if (entry.title.toLowerCase().includes(lower) || entry.basename.toLowerCase().includes(lower)) {
score += entry.title.toLowerCase() === lower ? 10 : 5;
if (!matchedOn.includes("title")) matchedOn.push("title");
}
// 2. Tags
if (entry.tags.some((t) => t.toLowerCase().includes(lower))) {
score += 5;
if (!matchedOn.includes("tag")) matchedOn.push("tag");
}
// 3. Headings
if (entry.headings.some((h) => h.toLowerCase().includes(lower))) {
score += 3;
if (!matchedOn.includes("heading")) matchedOn.push("heading");
}
// 4. Outgoing links
if (entry.links.some((l) => l.toLowerCase().includes(lower))) {
score += 4;
if (!matchedOn.includes("link")) matchedOn.push("link");
}
// 5. Concepts
if (entry.concepts.some((c) => c.toLowerCase().includes(lower))) {
score += 4;
if (!matchedOn.includes("concept")) matchedOn.push("concept");
}
// 6. Domain
if (entry.domain && entry.domain.toLowerCase().includes(lower)) {
score += 3;
if (!matchedOn.includes("domain")) matchedOn.push("domain");
}
}
// Context boost
if (contextFile && (contextLinks.has(entry.path) || entry.links.includes(contextFile))) {
score += 3;
}
if (score > 0) {
results.push({ entry, score, matchedOn });
}
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, limit);
}
/** Get all entries of a given type. */
getByType(type: string): IndexEntry[] {
return [...this.entries.values()].filter((e) => e.type === type);
}
/** Get all entries in a given domain. */
getByDomain(domain: string): IndexEntry[] {
return [...this.entries.values()].filter((e) => e.domain === domain);
}
/** Get known domains. */
getDomains(): string[] {
const set = new Set<string>();
for (const e of this.entries.values()) {
if (e.domain) set.add(e.domain);
}
return [...set].sort();
}
/** Get all concept names (type=concept entries). */
getConceptNames(): string[] {
return [...this.entries.values()]
.filter((e) => e.type === "concept")
.map((e) => e.basename);
}
/** Get entry by path. */
get(path: string): IndexEntry | undefined {
return this.entries.get(path);
}
/** Total entries in the index. */
get size(): number {
return this.entries.size;
}
/** Get all entries as an array. */
getAll(): IndexEntry[] {
return [...this.entries.values()];
}
// ── Internals ──────────────────────────────────────────────
private indexFile(file: TFile): void {
const meta: CachedMetadata | null = this.app.metadataCache.getFileCache(file);
const fm = meta?.frontmatter;
const title = meta?.headings?.find((h) => h.level === 1)?.heading ?? file.basename;
const headings = (meta?.headings ?? [])
.filter((h) => h.level === 2)
.map((h) => h.heading);
const tags = [
...(meta?.tags ?? []).map((t) => t.tag.replace(/^#/, "")),
...((fm?.tags as string[] | undefined) ?? []),
];
const links = (meta?.links ?? []).map((l) => l.link);
const concepts: string[] = Array.isArray(fm?.concepts) ? (fm!.concepts as string[]) : [];
const entry: IndexEntry = {
path: file.path,
basename: file.basename,
title,
type: fm?.type as string | undefined,
domain: fm?.domain as string | undefined,
status: fm?.status as string | undefined,
tags,
headings,
links,
backlinks: this.entries.get(file.path)?.backlinks ?? [],
concepts,
mtime: file.stat.mtime,
};
this.entries.set(file.path, entry);
}
private computeBacklinks(): void {
// Reset all backlinks
for (const entry of this.entries.values()) {
entry.backlinks = [];
}
// Build
for (const entry of this.entries.values()) {
for (const link of entry.links) {
// Resolve link to a path
const resolved = this.resolveLink(link, entry.path);
if (resolved) {
const target = this.entries.get(resolved);
if (target && !target.backlinks.includes(entry.path)) {
target.backlinks.push(entry.path);
}
}
}
}
}
private resolveLink(link: string, fromPath: string): string | null {
// Use Obsidian's resolve to handle relative paths, aliases, etc.
const file = this.app.metadataCache.getFirstLinkpathDest(link, fromPath);
return file?.path ?? null;
}
private getLinksOf(path: string): Set<string> {
const entry = this.entries.get(path);
if (!entry) return new Set();
const set = new Set<string>();
for (const link of entry.links) {
const resolved = this.resolveLink(link, path);
if (resolved) set.add(resolved);
}
for (const bl of entry.backlinks) {
set.add(bl);
}
return set;
}
private tokenize(query: string): string[] {
// Split on whitespace, punctuation, and common separators
return query
.split(/[\s,;、,;]+/)
.map((t) => t.trim())
.filter((t) => t.length >= 1);
}
}

View file

@ -0,0 +1,2 @@
export { KnowledgeIndexService } from "./KnowledgeIndexService";
export type { IndexEntry, SearchResult, SearchOptions } from "./KnowledgeIndexService";

View file

@ -0,0 +1,37 @@
/**
* Tolerant JSON extractor for LLM outputs.
*
* LLMs frequently wrap JSON in ```json ... ``` fences or surround it with
* explanatory prose. These helpers locate and parse the JSON payload safely.
*/
/** Locate the JSON-looking substring inside an LLM response. */
export function extractJson(raw: string): string {
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fenced) return fenced[1];
const braced = raw.match(/(\{[\s\S]*\})/);
if (braced) return braced[1];
return raw;
}
/**
* Parse JSON from an LLM response, returning `fallback` on any failure.
* Never throws.
*/
export function parseJsonSafe<T>(raw: string, fallback: T): T {
try {
return JSON.parse(extractJson(raw).trim()) as T;
} catch {
return fallback;
}
}
/**
* Parse JSON from an LLM response. Throws if the payload is not valid JSON.
* Use when an empty/default value is not acceptable upstream.
*/
export function parseJsonStrict<T>(raw: string): T {
return JSON.parse(extractJson(raw).trim()) as T;
}

87
src/core/llm/LLMClient.ts Normal file
View file

@ -0,0 +1,87 @@
import { requestUrl } from "obsidian";
import { DeepSeekSettings } from "../../types";
/**
* Unified LLM chat-completion client.
*
* Centralizes:
* - OpenAI-compatible chat-completions request shape
* - API-key validation
* - Error normalization
* - Default headers / timeouts
*
* All AI feature modules (assistant, classifier, completer, planner, ...)
* should use this client instead of calling `requestUrl` directly.
*
* Provider-agnostic by design: the request payload conforms to the
* `chat/completions` schema used by DeepSeek, OpenAI, and most compatible
* providers. Adding a new provider should only require subclassing or
* swapping out this module.
*/
export interface LLMChatOptions {
/** Optional system prompt. Sent as the first message when provided. */
systemPrompt?: string;
/** Required user prompt. */
userPrompt: string;
/** Sampling temperature. Defaults to 0.5. */
temperature?: number;
/**
* Override the model name configured in settings.
* Useful for tasks that always need a specific model.
*/
model?: string;
}
export class LLMError extends Error {
constructor(public status: number, message: string, public body?: string) {
super(message);
this.name = "LLMError";
}
}
export class LLMClient {
constructor(private settings: DeepSeekSettings) {}
/** Throw a friendly error if the API key is not configured. */
ensureApiKey(): void {
if (!this.settings.apiKey) {
throw new Error("请先在插件设置中配置 API Key");
}
}
/**
* Run a single chat-completion request.
* @returns The raw assistant message content (string, possibly empty).
*/
async chat(options: LLMChatOptions): Promise<string> {
this.ensureApiKey();
const messages: { role: "system" | "user"; content: string }[] = [];
if (options.systemPrompt) {
messages.push({ role: "system", content: options.systemPrompt });
}
messages.push({ role: "user", content: options.userPrompt });
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: options.model ?? this.settings.model,
messages,
temperature: options.temperature ?? 0.5,
}),
throw: false,
});
if (res.status !== 200) {
throw new LLMError(res.status, `LLM API 错误: ${res.status}`, res.text);
}
return res.json?.choices?.[0]?.message?.content ?? "";
}
}

3
src/core/llm/index.ts Normal file
View file

@ -0,0 +1,3 @@
export { LLMClient, LLMError } from "./LLMClient";
export type { LLMChatOptions } from "./LLMClient";
export { extractJson, parseJsonSafe, parseJsonStrict } from "./JsonExtractor";

View file

@ -0,0 +1,68 @@
import { ScheduleTrigger } from "./types";
/**
* Determine whether a task is due and calculate the next run time.
*/
export class NextRunCalculator {
/** Check if the task should fire now. */
isDue(trigger: ScheduleTrigger, lastRunAt: string | undefined, now: Date): boolean {
switch (trigger.type) {
case "on-startup":
// Fire once per session — if never run this session
return !lastRunAt;
case "interval": {
if (!lastRunAt) return true;
const elapsed = now.getTime() - new Date(lastRunAt).getTime();
return elapsed >= trigger.minutes * 60_000;
}
case "daily": {
const [h, m] = trigger.time.split(":").map(Number);
if (now.getHours() < h || (now.getHours() === h && now.getMinutes() < m)) return false;
if (!lastRunAt) return true;
const lastDate = new Date(lastRunAt).toDateString();
return lastDate !== now.toDateString();
}
case "weekly": {
if (now.getDay() !== trigger.weekday) return false;
const [h, m] = trigger.time.split(":").map(Number);
if (now.getHours() < h || (now.getHours() === h && now.getMinutes() < m)) return false;
if (!lastRunAt) return true;
const elapsed = now.getTime() - new Date(lastRunAt).getTime();
return elapsed >= 6 * 24 * 60 * 60_000; // at least 6 days since last
}
}
}
/** Calculate the next scheduled run (for display). */
getNextRun(trigger: ScheduleTrigger, now: Date): Date {
switch (trigger.type) {
case "on-startup":
return now; // always "next startup"
case "interval": {
return new Date(now.getTime() + trigger.minutes * 60_000);
}
case "daily": {
const [h, m] = trigger.time.split(":").map(Number);
const next = new Date(now);
next.setHours(h, m, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next;
}
case "weekly": {
const [h, m] = trigger.time.split(":").map(Number);
const next = new Date(now);
const diff = (trigger.weekday - now.getDay() + 7) % 7;
next.setDate(now.getDate() + diff);
next.setHours(h, m, 0, 0);
if (next <= now) next.setDate(next.getDate() + 7);
return next;
}
}
}
}

View file

@ -0,0 +1,157 @@
import { Notice } from "obsidian";
import type DeepSeekPlugin from "../../main";
import { ScheduledTaskConfig, ScheduledTaskResult } from "./types";
import { NextRunCalculator } from "./NextRunCalculator";
import { PlanExecutor } from "../execution";
import { PlanBuilder } from "../execution";
import { PlanDraftStore } from "../execution";
import { KnowledgeIndexService } from "../knowledge";
import { todayIso } from "../schema";
/**
* ScheduledTaskRunner checks due tasks every 60s while Obsidian is open.
*
* Safety:
* - "notify-only": shows a Notice and optionally writes a report.
* - "create-plan-only": generates an ExecutionPlan draft but does NOT apply.
* - "auto-execute-low-risk": applies only if plan.riskLevel === "low".
*/
export class ScheduledTaskRunner {
private running = new Set<string>();
private calc = new NextRunCalculator();
private intervalId: number | null = null;
constructor(
private plugin: DeepSeekPlugin,
private tasks: ScheduledTaskConfig[]
) {}
start(): void {
// Check every 60 seconds
this.intervalId = window.setInterval(() => void this.tick(), 60_000);
this.plugin.registerInterval(this.intervalId);
// Immediate catch-up for on-startup tasks
void this.tick();
}
stop(): void {
if (this.intervalId !== null) {
window.clearInterval(this.intervalId);
this.intervalId = null;
}
}
private async tick(): Promise<void> {
const now = new Date();
for (const task of this.tasks) {
if (!task.enabled) continue;
if (this.running.has(task.id)) continue;
if (!this.calc.isDue(task.trigger, task.lastRunAt, now)) continue;
await this.runTask(task, now);
}
}
private async runTask(task: ScheduledTaskConfig, now: Date): Promise<void> {
this.running.add(task.id);
try {
const result = await this.executeTaskKind(task);
task.lastRunAt = now.toISOString();
task.nextRunAt = this.calc.getNextRun(task.trigger, now).toISOString();
if (result.success) {
new Notice(`${task.name}${result.message}`);
}
} catch (err) {
new Notice(`${task.name} 失败:${(err as Error).message}`);
} finally {
this.running.delete(task.id);
}
}
private async executeTaskKind(task: ScheduledTaskConfig): Promise<ScheduledTaskResult> {
const base: Omit<ScheduledTaskResult, "success" | "message"> = {
taskId: task.id,
ranAt: new Date().toISOString(),
};
switch (task.kind) {
case "knowledge-debt-scan":
return this.runDebtScan(task, base);
case "baidu-backup":
return this.runBaiduBackup(task, base);
case "question-graph-rebuild":
case "reading-review":
case "stale-draft-review":
case "custom":
// Placeholder — generate notice only
return { ...base, success: true, message: "已完成扫描(详细实现待补充)" };
}
}
private async runDebtScan(
task: ScheduledTaskConfig,
base: Omit<ScheduledTaskResult, "success" | "message">
): Promise<ScheduledTaskResult> {
const index = this.plugin.knowledgeIndex;
const emptyConcepts = index.getByType("concept").filter((e) => e.status === "empty" || e.status === "pending");
const total = emptyConcepts.length;
if (task.safety === "notify-only") {
return { ...base, success: true, message: `发现 ${total} 个空概念页` };
}
// Build an execution plan for creating the report
const reportContent = this.buildDebtReport(index);
const plan = new PlanBuilder("每日知识债务扫描", "scheduler")
.createFile(`Knowledge/_Reports/${todayIso()}-知识债务.md`, reportContent)
.build();
if (task.safety === "auto-execute-low-risk" && plan.riskLevel === "low") {
const record = await new PlanExecutor(this.plugin.app).execute(plan);
return {
...base,
success: record.success,
message: record.success
? `报告已生成(${total} 个空概念)`
: `执行失败:${record.error ?? "未知错误"}`,
};
}
// create-plan-only: persist as draft, do NOT execute
await new PlanDraftStore(this.plugin.app).persistDraft(plan);
return { ...base, success: true, message: `已生成待确认计划(${total} 个空概念)` };
}
private async runBaiduBackup(
task: ScheduledTaskConfig,
base: Omit<ScheduledTaskResult, "success" | "message">
): Promise<ScheduledTaskResult> {
const cfg = this.plugin.settings.baiduSync;
if (!cfg.enabled || !cfg.autoBackup || !cfg.accessToken) {
return { ...base, success: false, message: "百度自动备份未启用或未授权" };
}
// Delegate to existing sync service (config sync only in v2.0)
const { BaiduSyncService } = await import("../../features/sync/BaiduSyncService");
const service = new BaiduSyncService(this.plugin.app, cfg);
const adapter = this.plugin.app.vault.adapter as unknown as { basePath?: string };
const ok = await service.pushConfig(this.plugin.settings, adapter.basePath ?? "device");
return { ...base, success: ok, message: ok ? "配置已同步" : "配置同步失败" };
}
private buildDebtReport(index: KnowledgeIndexService): string {
const empty = index.getByType("concept").filter((e) => e.status === "empty" || e.status === "pending");
const lines = [
`---\ntype: report\nreport_type: knowledge-debt\ncreated_at: ${todayIso()}\n---\n`,
`# 知识债务报告 ${todayIso()}\n`,
`## 空概念页 (${empty.length})\n`,
...empty.slice(0, 30).map((e) => `- [[${e.basename}]]${e.domain ? ` [${e.domain}]` : ""}`),
empty.length > 30 ? `\n...还有 ${empty.length - 30}\n` : "",
];
return lines.join("\n");
}
}

View file

@ -0,0 +1,9 @@
export { ScheduledTaskRunner } from "./ScheduledTaskRunner";
export { NextRunCalculator } from "./NextRunCalculator";
export type {
ScheduledTaskKind,
ScheduleTrigger,
ScheduledTaskSafety,
ScheduledTaskConfig,
ScheduledTaskResult,
} from "./types";

View file

@ -0,0 +1,48 @@
/**
* Scheduled task types for IStart-Note-AI.
*
* Tasks run ONLY while Obsidian is open and the plugin is enabled.
* If a scheduled run is missed, the runner offers catch-up on next launch.
*/
export type ScheduledTaskKind =
| "knowledge-debt-scan"
| "baidu-backup"
| "question-graph-rebuild"
| "reading-review"
| "stale-draft-review"
| "custom";
export type ScheduleTrigger =
| { type: "on-startup" }
| { type: "interval"; minutes: number }
| { type: "daily"; time: string } // "22:00" (HH:mm, local)
| { type: "weekly"; weekday: number; time: string }; // weekday: 0=Sun
export type ScheduledTaskSafety =
| "notify-only"
| "create-plan-only"
| "auto-execute-low-risk";
export interface ScheduledTaskConfig {
id: string;
name: string;
enabled: boolean;
kind: ScheduledTaskKind;
trigger: ScheduleTrigger;
safety: ScheduledTaskSafety;
lastRunAt?: string; // ISO
nextRunAt?: string; // ISO
scope?: {
paths?: string[];
types?: string[];
domains?: string[];
};
}
export interface ScheduledTaskResult {
taskId: string;
ranAt: string;
success: boolean;
message: string;
}

20
src/core/schema.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* Frontmatter schema version for plugin-managed notes.
*
* Bump when:
* - The shape of frontmatter fields changes (rename/remove/retype).
* - The semantics of an existing field change.
*
* Migration code lives in `src/core/schema/migrations/` (added on first bump).
*
* Currently affects:
* - Concept pages (`type: concept`)
* - Question Q&A notes (`type: question`)
* - Domain index pages (`type: domain-index`)
*/
export const SCHEMA_VERSION = 1;
/** Today's date in ISO `YYYY-MM-DD` form, in the local timezone. */
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}

BIN
src/features/.DS_Store vendored

Binary file not shown.

View file

@ -0,0 +1,118 @@
import { App, Modal, Setting } from "obsidian";
import {
ArtifactType, ArtifactUsageMode, ArtifactSourceScope, EvidencePolicy,
ArtifactBuildParams,
ARTIFACT_TYPE_LABELS, USAGE_MODE_LABELS, SOURCE_SCOPE_LABELS, EVIDENCE_POLICY_LABELS,
} from "../../core/artifact";
/**
* Five-question builder for creating an execution artifact.
* Collects: type, target, usage, source scope, evidence policy.
*/
export class ArtifactBuilderModal extends Modal {
private params: ArtifactBuildParams = {
artifactType: "checklist",
target: "",
usageMode: "template",
sourceScope: "current-note",
evidencePolicy: "balanced",
};
constructor(
app: App,
private contextHint: string,
private defaultScope: ArtifactSourceScope,
private onSubmit: (params: ArtifactBuildParams) => void
) {
super(app);
this.params.sourceScope = defaultScope;
}
onOpen() {
const { contentEl } = this;
this.titleEl.setText("从知识生成执行资产");
if (this.contextHint) {
contentEl.createEl("p", {
text: this.contextHint,
attr: { style: "color: var(--text-muted); font-size: 13px; margin-bottom: 12px;" },
});
}
// 1. Type
new Setting(contentEl)
.setName("你想生成什么?")
.addDropdown((d) => {
for (const [key, label] of Object.entries(ARTIFACT_TYPE_LABELS)) {
d.addOption(key, label);
}
d.setValue(this.params.artifactType);
d.onChange((v: string) => { this.params.artifactType = v as ArtifactType; });
});
// 2. Target
new Setting(contentEl)
.setName("用于什么对象或场景?")
.setDesc("例如:婴儿、发布流程、用户访谈、学习复盘")
.addText((t) =>
t.setPlaceholder("输入对象或场景")
.setValue(this.params.target)
.onChange((v) => { this.params.target = v.trim(); })
);
// 3. Usage mode
new Setting(contentEl)
.setName("使用方式")
.addDropdown((d) => {
for (const [key, label] of Object.entries(USAGE_MODE_LABELS)) {
d.addOption(key, label);
}
d.setValue(this.params.usageMode);
d.onChange((v: string) => { this.params.usageMode = v as ArtifactUsageMode; });
});
// 4. Source scope
new Setting(contentEl)
.setName("来源范围")
.addDropdown((d) => {
for (const [key, label] of Object.entries(SOURCE_SCOPE_LABELS)) {
d.addOption(key, label);
}
d.setValue(this.params.sourceScope);
d.onChange((v: string) => { this.params.sourceScope = v as ArtifactSourceScope; });
});
// 5. Evidence policy
new Setting(contentEl)
.setName("依据要求")
.addDropdown((d) => {
for (const [key, label] of Object.entries(EVIDENCE_POLICY_LABELS)) {
d.addOption(key, label);
}
d.setValue(this.params.evidencePolicy);
d.onChange((v: string) => { this.params.evidencePolicy = v as EvidencePolicy; });
});
// Optional instruction
const textArea = contentEl.createEl("textarea", {
attr: { placeholder: "可选:补充说明或具体要求...", rows: "2", style: "width: 100%; margin: 8px 0;" },
});
textArea.addEventListener("input", () => {
this.params.instruction = textArea.value.trim() || undefined;
});
// Actions
new Setting(contentEl)
.addButton((btn) =>
btn.setButtonText("生成预览").setCta().onClick(() => {
this.close();
this.onSubmit(this.params);
})
)
.addButton((btn) => btn.setButtonText("取消").onClick(() => this.close()));
}
onClose() {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,220 @@
import { App, TFile, Notice, normalizePath } from "obsidian";
import { DeepSeekSettings } from "../../types";
import { LLMClient, parseJsonSafe } from "../../core/llm";
import { KnowledgeIndexService } from "../../core/knowledge";
import { todayIso } from "../../core/schema";
import {
ArtifactBuildParams, ArtifactPromptBuilder, ArtifactRenderer,
ArtifactValidator, ExecutionArtifact, ArtifactSourceScope,
} from "../../core/artifact";
import { ArtifactBuilderModal } from "./ArtifactBuilderModal";
import { ArtifactPreviewModal, ArtifactSaveChoice } from "./ArtifactPreviewModal";
/**
* Orchestrates the Execution Artifact Builder flow:
* context detection builder modal LLM validate preview save
*/
export class ArtifactFeatureController {
constructor(
private app: App,
private settings: DeepSeekSettings,
private knowledgeIndex: KnowledgeIndexService
) {}
/** Entry point: detect context and open builder. */
openBuilder(): void {
const editor = this.app.workspace.activeEditor?.editor ?? null;
const activeFile = this.app.workspace.getActiveFile();
const selection = editor?.getSelection().trim() ?? "";
let defaultScope: ArtifactSourceScope = "current-note";
let contextHint = "";
if (selection) {
defaultScope = "selection";
contextHint = `已选中 ${selection.length}`;
} else if (activeFile?.path.includes("Reading/")) {
defaultScope = "reading-project";
const projectName = activeFile.path.split("/").filter(Boolean).find((_, i, arr) =>
i > 0 && arr[i - 1] === "Reading"
) ?? "";
contextHint = projectName ? `检测到阅读项目:${projectName}` : `当前在 Reading 目录`;
} else if (activeFile) {
contextHint = `当前笔记:${activeFile.basename}`;
}
new ArtifactBuilderModal(this.app, contextHint, defaultScope, (params) => {
void this.generate(params, selection, activeFile);
}).open();
}
// ── Generation ─────────────────────────────────────────────
private async generate(
params: ArtifactBuildParams,
selection: string,
activeFile: TFile | null
): Promise<void> {
const notice = new Notice("⏳ 正在生成执行资产...", 0);
try {
const context = await this.gatherContext(params, selection, activeFile);
const promptBuilder = new ArtifactPromptBuilder();
const systemPrompt = promptBuilder.buildSystemPrompt();
const userPrompt = promptBuilder.buildUserPrompt(params, context);
const llm = new LLMClient(this.settings);
const raw = await llm.chat({ systemPrompt, userPrompt, temperature: 0.4 });
const parsed = parseJsonSafe<Partial<ExecutionArtifact> | null>(raw, null);
if (!parsed) {
notice.hide();
new Notice("AI 未能生成有效的执行资产结构");
return;
}
const validator = new ArtifactValidator();
const artifact = validator.normalize({
...parsed,
sourceScope: params.sourceScope,
evidencePolicy: params.evidencePolicy,
usageMode: params.usageMode,
});
notice.hide();
this.showPreview(artifact, params, selection, activeFile);
} catch (err) {
notice.hide();
new Notice(`${(err as Error).message}`);
}
}
// ── Preview ────────────────────────────────────────────────
private showPreview(
artifact: ExecutionArtifact,
params: ArtifactBuildParams,
selection: string,
activeFile: TFile | null
): void {
new ArtifactPreviewModal(this.app, artifact, (choice: ArtifactSaveChoice) => {
switch (choice) {
case "save-template":
void this.saveTemplate(artifact);
break;
case "save-and-run":
void this.saveTemplateAndRun(artifact);
break;
case "regenerate":
void this.generate(params, selection, activeFile);
break;
}
}).open();
}
// ── Save ───────────────────────────────────────────────────
private async saveTemplate(artifact: ExecutionArtifact): Promise<TFile> {
const folder = normalizePath("Knowledge/Artifacts");
await this.ensureFolder(folder);
const renderer = new ArtifactRenderer();
const content = renderer.renderTemplate(artifact);
const path = await this.uniquePath(folder, artifact.title);
const file = await this.app.vault.create(path, content);
new Notice(`✅ 模板已保存:${artifact.title}`);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
return file;
}
private async saveTemplateAndRun(artifact: ExecutionArtifact): Promise<void> {
await this.saveTemplate(artifact);
const runFolder = normalizePath("Knowledge/Artifact Runs");
await this.ensureFolder(runFolder);
const renderer = new ArtifactRenderer();
const today = todayIso();
const runContent = renderer.renderRun(artifact, today);
const runPath = await this.uniquePath(runFolder, `${today} ${artifact.title}`);
const runFile = await this.app.vault.create(runPath, runContent);
new Notice(`✅ 今日执行记录已创建`);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(runFile);
}
// ── Context gathering ──────────────────────────────────────
private async gatherContext(
params: ArtifactBuildParams,
selection: string,
activeFile: TFile | null
): Promise<string> {
switch (params.sourceScope) {
case "selection":
return selection || "(无选中内容)";
case "current-note": {
if (!activeFile) return "(无当前笔记)";
const content = await this.app.vault.cachedRead(activeFile);
return content.slice(0, 3000);
}
case "reading-project": {
if (!activeFile) return "(无当前文件)";
const folder = activeFile.path.split("/").slice(0, -1).join("/");
const siblings = this.app.vault.getMarkdownFiles()
.filter((f) => f.path === folder || f.path.startsWith(folder + "/"))
.sort((a, b) => a.path.localeCompare(b.path))
.slice(0, 10);
const parts: string[] = [];
for (const f of siblings) {
const content = await this.app.vault.cachedRead(f);
parts.push(`--- [[${f.path}|${f.basename}]] ---\n${content.slice(0, 500)}`);
}
return parts.join("\n\n") || "(阅读项目无内容)";
}
case "related-vault": {
// Use params.target + artifactType + basename for richer query
const queryParts = [params.target, params.artifactType, activeFile?.basename].filter(Boolean);
const query = queryParts.join(" ");
const results = this.knowledgeIndex.search(query, { limit: 6 });
const parts: string[] = [];
for (const { entry } of results) {
const file = this.app.vault.getAbstractFileByPath(entry.path);
if (!file || !(file instanceof TFile)) continue;
const content = await this.app.vault.cachedRead(file);
parts.push(`--- [[${entry.path}|${entry.title}]] ---\n${content.slice(0, 500)}`);
}
return parts.join("\n\n") || "(未找到相关知识)";
}
case "freeform":
return "(自由生成模式,无固定来源)";
}
}
// ── Helpers ────────────────────────────────────────────────
private async ensureFolder(path: string): Promise<void> {
if (!this.app.vault.getAbstractFileByPath(path)) {
await this.app.vault.createFolder(path);
}
}
private async uniquePath(folder: string, title: string): Promise<string> {
const safeName = title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 50);
let path = normalizePath(`${folder}/${safeName}.md`);
let suffix = 2;
while (this.app.vault.getAbstractFileByPath(path)) {
path = normalizePath(`${folder}/${safeName}-${suffix}.md`);
suffix++;
}
return path;
}
}

View file

@ -0,0 +1,85 @@
import { App, Component, MarkdownRenderer, Modal, Setting } from "obsidian";
import { ExecutionArtifact, ArtifactRenderer, ArtifactValidator } from "../../core/artifact";
import type { ValidationResult } from "../../core/artifact/ArtifactValidator";
export type ArtifactSaveChoice = "save-template" | "save-and-run" | "regenerate";
/**
* Preview modal showing generated artifact items, sources, and inferred items.
*/
export class ArtifactPreviewModal extends Modal {
private component: Component;
private validation: ValidationResult;
constructor(
app: App,
private artifact: ExecutionArtifact,
private onChoice: (choice: ArtifactSaveChoice) => void
) {
super(app);
this.component = new Component();
const validator = new ArtifactValidator();
this.validation = validator.validate(artifact);
}
onOpen() {
const { contentEl } = this;
this.titleEl.setText(`预览:${this.artifact.title}`);
// Stats bar
const { stats } = this.validation;
const statsEl = contentEl.createDiv({ attr: { style: "display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px; color: var(--text-muted);" } });
statsEl.createSpan({ text: `条目:${stats.totalItems}` });
statsEl.createSpan({ text: `有来源:${stats.withSource}` });
if (stats.inferred > 0) statsEl.createSpan({ text: `AI 推断:${stats.inferred}`, attr: { style: "color: var(--text-warning);" } });
if (stats.highRisk > 0) statsEl.createSpan({ text: `高关注:${stats.highRisk}`, attr: { style: "color: var(--text-error);" } });
// File impact notice
const safeName = this.artifact.title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 50);
contentEl.createEl("div", {
attr: { style: "font-size: 12px; color: var(--text-muted); margin-bottom: 8px; padding: 6px 8px; background: var(--background-secondary); border-radius: 4px;" },
}).innerHTML = `将创建:<br>• <code>Knowledge/Artifacts/${safeName}.md</code><br>• <code>Knowledge/Artifact Runs/${new Date().toISOString().slice(0, 10)} ${safeName}.md</code>(仅"保存并生成今日记录"时)`;
// Warnings
if (this.validation.warnings.length > 0) {
const warnEl = contentEl.createDiv({ attr: { style: "margin-bottom: 12px;" } });
for (const w of this.validation.warnings) {
warnEl.createEl("p", { text: `${w}`, attr: { style: "color: var(--text-warning); font-size: 13px; margin: 2px 0;" } });
}
}
// Rendered preview
const previewEl = contentEl.createDiv({
attr: { style: "max-height: 50vh; overflow-y: auto; border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 12px; margin-bottom: 16px;" },
});
const renderer = new ArtifactRenderer();
const previewMd = renderer.renderTemplate(this.artifact);
void MarkdownRenderer.render(this.app, previewMd, previewEl, "", this.component);
// Actions
new Setting(contentEl)
.addButton((btn) =>
btn.setButtonText("保存为模板").setCta().onClick(() => {
this.close();
this.onChoice("save-template");
})
)
.addButton((btn) =>
btn.setButtonText("保存并生成今日记录").onClick(() => {
this.close();
this.onChoice("save-and-run");
})
)
.addButton((btn) =>
btn.setButtonText("重新生成").onClick(() => {
this.close();
this.onChoice("regenerate");
})
);
}
onClose() {
this.component.unload();
this.contentEl.empty();
}
}

View file

@ -1,5 +1,6 @@
import { App, Modal, Setting, MarkdownRenderer, Component } from "obsidian";
import { App, Modal, Setting, MarkdownRenderer, Component, Notice, normalizePath, TFile } from "obsidian";
import { AssistantResult } from "../../ai/AIAssistant";
import { todayIso } from "../../core/schema";
const QUICK_TAGS = [
{ label: "扩写", value: "扩写这段内容" },
@ -18,7 +19,7 @@ const QUICK_TAGS = [
*/
export class AssistantInputModal extends Modal {
private instruction = "";
private inputEl: HTMLTextAreaElement;
private inputEl!: HTMLTextAreaElement;
constructor(
app: App,
@ -32,24 +33,19 @@ export class AssistantInputModal extends Modal {
onOpen() {
const { contentEl } = this;
// 上下文提示
if (this.contextHint) {
contentEl.createEl("p", { text: this.contextHint, cls: "istart-assistant-context" });
}
// 输入框
this.inputEl = contentEl.createEl("textarea", {
attr: { placeholder: "输入你的需求...(留空 = AI 智能判断)", rows: "3" },
cls: "istart-assistant-input",
});
this.inputEl.addEventListener("input", () => { this.instruction = this.inputEl.value; });
// Ctrl/Cmd+Enter 提交
this.inputEl.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { this.submit(); }
});
// 快捷标签
const tagsEl = contentEl.createDiv({ cls: "istart-assistant-tags" });
for (const tag of QUICK_TAGS) {
const btn = tagsEl.createEl("button", { text: tag.label, cls: "istart-assistant-tag" });
@ -60,7 +56,6 @@ export class AssistantInputModal extends Modal {
});
}
// 按钮
new Setting(contentEl)
.addButton((btn) => btn.setButtonText("执行 (Ctrl+Enter)").setCta().onClick(() => this.submit()))
.addButton((btn) => btn.setButtonText("取消").onClick(() => this.close()));
@ -76,8 +71,151 @@ export class AssistantInputModal extends Modal {
onClose() { this.contentEl.empty(); }
}
// ── Result Modal helpers ─────────────────────────────────────
interface ResultAction {
label: string;
cta?: boolean;
callback: () => void;
}
/**
* Determine the best actions based on mode + content characteristics.
* Returns [primary, ...secondary] max 3 actions total (excluding retry/close).
*/
function buildSmartActions(
app: App,
result: AssistantResult,
onWriteToDoc: () => void,
onRetry: () => void,
onCreateConcept?: () => void
): { primary: ResultAction; secondary: ResultAction[] } {
const content = result.content;
// Heuristics
const hasCheckboxes = (content.match(/- \[ \]/g) || []).length >= 2;
const looksLikeConcept = /^#\s+.{2,20}\n\n/.test(content) &&
(content.includes("## 定义") || content.includes("## 解释") || content.includes("## 核心"));
const isLong = content.length > 500;
switch (result.mode) {
case "replace":
return {
primary: { label: "替换选中内容", cta: true, callback: onWriteToDoc },
secondary: [
{ label: "复制", callback: () => copyToClipboard(content) },
],
};
case "insert":
return {
primary: { label: "插入到光标位置", cta: true, callback: onWriteToDoc },
secondary: [
...(isLong ? [{ label: "保存为新笔记", callback: () => saveAsNote(app, result) }] : []),
{ label: "复制", callback: () => copyToClipboard(content) },
],
};
case "append":
return {
primary: { label: "追加到文档末尾", cta: true, callback: onWriteToDoc },
secondary: [
{ label: "保存为新笔记", callback: () => saveAsNote(app, result) },
],
};
case "show":
default: {
// For show mode, pick the best primary based on content
if (hasCheckboxes) {
return {
primary: { label: "保存为执行计划", cta: true, callback: () => saveAsPlan(app, result) },
secondary: [
{ label: "插入到光标位置", callback: onWriteToDoc },
{ label: "复制", callback: () => copyToClipboard(content) },
],
};
}
if (looksLikeConcept && onCreateConcept) {
return {
primary: { label: "创建为概念页", cta: true, callback: onCreateConcept },
secondary: [
{ label: "保存为新笔记", callback: () => saveAsNote(app, result) },
{ label: "插入到光标位置", callback: onWriteToDoc },
],
};
}
// Default show: save as note
return {
primary: { label: "保存为新笔记", cta: true, callback: () => saveAsNote(app, result) },
secondary: [
{ label: "插入到光标位置", callback: onWriteToDoc },
...(onCreateConcept ? [{ label: "创建为概念页", callback: onCreateConcept }] : []),
],
};
}
}
}
async function saveAsNote(app: App, result: AssistantResult): Promise<void> {
const folder = normalizePath("Knowledge/Notes");
if (!app.vault.getAbstractFileByPath(folder)) {
await app.vault.createFolder(folder);
}
const today = todayIso();
const title = extractTitle(result.content) || "AI 笔记";
const safeName = title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 40);
let path = normalizePath(`${folder}/${today}-${safeName}.md`);
let suffix = 2;
while (app.vault.getAbstractFileByPath(path)) {
path = normalizePath(`${folder}/${today}-${safeName}-${suffix}.md`);
suffix++;
}
const file = await app.vault.create(path, result.content);
new Notice(`✅ 已保存到 ${file.path}`);
const leaf = app.workspace.getLeaf(false);
await leaf.openFile(file);
}
async function saveAsPlan(app: App, result: AssistantResult): Promise<void> {
const folder = normalizePath("Knowledge/Plans");
if (!app.vault.getAbstractFileByPath(folder)) {
await app.vault.createFolder(folder);
}
const today = todayIso();
const title = extractTitle(result.content) || "执行计划";
const safeName = title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 40);
let path = normalizePath(`${folder}/${today}-${safeName}.md`);
let suffix = 2;
while (app.vault.getAbstractFileByPath(path)) {
path = normalizePath(`${folder}/${today}-${safeName}-${suffix}.md`);
suffix++;
}
const frontmatter = `---\ntype: plan\nstatus: active\ncreated_at: ${today}\n---\n\n`;
const file = await app.vault.create(path, frontmatter + result.content);
new Notice(`✅ 执行计划已保存`);
const leaf = app.workspace.getLeaf(false);
await leaf.openFile(file);
}
function copyToClipboard(content: string): void {
navigator.clipboard.writeText(content).then(
() => new Notice("✅ 已复制到剪贴板"),
() => new Notice("❌ 复制失败")
);
}
function extractTitle(content: string): string {
const match = content.match(/^#\s+(.+)/m);
return match ? match[1].trim() : "";
}
/**
* AI
*
* Smart actions: system recommends the best action based on mode + content.
* Mobile-safe: flex layout with fixed bottom action bar.
*/
export class AssistantResultModal extends Modal {
private component: Component;
@ -95,31 +233,53 @@ export class AssistantResultModal extends Modal {
onOpen() {
const { contentEl } = this;
contentEl.addClass("istart-result-modal");
this.titleEl.setText(this.result.explanation ?? "AI 助手结果");
// 渲染预览
const previewEl = contentEl.createDiv({ cls: "istart-assistant-preview" });
// Preview area (scrollable)
const previewEl = contentEl.createDiv({ cls: "istart-result-preview" });
void MarkdownRenderer.render(this.app, this.result.content, previewEl, "", this.component);
// 模式提示
// Mode hint
const modeLabels: Record<string, string> = {
replace: "将替换选中内容",
insert: "将插入到光标位置",
append: "将追加到文件末尾",
show: "仅展示(不修改文件)",
show: "仅展示",
};
contentEl.createEl("p", {
text: `📌 ${modeLabels[this.result.mode] || ""}`,
cls: "istart-assistant-mode-hint",
text: modeLabels[this.result.mode] || "",
cls: "istart-result-mode-hint",
});
// 按钮
const btnSetting = new Setting(contentEl);
btnSetting.addButton((btn) => btn.setButtonText("插入当前文档").setCta().onClick(() => { this.close(); this.onConfirm(); }));
btnSetting.addButton((btn) => btn.setButtonText("创建为新概念页").onClick(() => { this.close(); this.onCreateConcept?.(); }));
btnSetting
.addButton((btn) => btn.setButtonText("重新生成").onClick(() => { this.close(); this.onRetry(); }))
.addButton((btn) => btn.setButtonText("关闭").onClick(() => this.close()));
// Action bar (fixed at bottom)
const actionBar = contentEl.createDiv({ cls: "istart-result-actions" });
const { primary, secondary } = buildSmartActions(
this.app,
this.result,
this.onConfirm,
this.onRetry,
this.onCreateConcept
);
// Primary button
const primarySetting = new Setting(actionBar);
primarySetting.addButton((btn) =>
btn.setButtonText(primary.label).setCta().onClick(() => { this.close(); primary.callback(); })
);
// Secondary buttons
for (const action of secondary.slice(0, 2)) {
primarySetting.addButton((btn) =>
btn.setButtonText(action.label).onClick(() => { this.close(); action.callback(); })
);
}
// Retry + close row
const utilBar = new Setting(actionBar);
utilBar.addButton((btn) => btn.setButtonText("重新生成").onClick(() => { this.close(); this.onRetry(); }));
utilBar.addButton((btn) => btn.setButtonText("关闭").onClick(() => this.close()));
}
onClose() { this.component.unload(); this.contentEl.empty(); }

View file

@ -1,4 +1,4 @@
import { App, Modal, TFile } from "obsidian";
import { App, Modal, setIcon, TFile } from "obsidian";
export interface PanelAction {
id: string;
@ -39,9 +39,9 @@ export class CommandPanelModal extends Modal {
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 iconEl = row.createSpan({ cls: "istart-panel-action-icon" });
setIcon(iconEl, action.icon);
const textEl = row.createDiv({ cls: "istart-panel-action-text" });
textEl.createEl("span", { text: action.label, cls: "istart-panel-action-label" });

View file

@ -27,7 +27,7 @@ export class DepthSelectModal extends Modal {
.addOption("light", "轻量")
.addOption("standard", "标准(推荐)")
.setValue(this.depth)
.onChange((v: CompletionDepth) => (this.depth = v))
.onChange((v: string) => (this.depth = v as CompletionDepth))
);
new Setting(contentEl)
@ -143,7 +143,7 @@ export class BatchScanModal extends Modal {
.addOption("light", "轻量")
.addOption("standard", "标准(推荐)")
.setValue(depth)
.onChange((v: CompletionDepth) => (depth = v))
.onChange((v: string) => (depth = v as CompletionDepth))
);
new Setting(contentEl)

View file

@ -1,5 +1,6 @@
import { App, TFile, parseYaml, stringifyYaml, normalizePath } from "obsidian";
import { ConceptCompletionResult, CompletionDepth, DeepSeekSettings } from "../../types";
import { SCHEMA_VERSION, todayIso } from "../../core/schema";
export interface ConceptPageInfo {
file: TFile;
@ -323,12 +324,12 @@ export class ConceptPageManager {
result: ConceptCompletionResult
): Record<string, unknown> | null {
if (!fm) return null;
const today = new Date().toISOString().slice(0, 10);
return {
...fm,
schema_version: SCHEMA_VERSION,
status: "completed",
completion_status: "completed",
updated_at: today,
updated_at: todayIso(),
...(result.tags.length > 0 ? { tags: result.tags } : {}),
...(result.domain ? { domain: result.domain } : {}),
};
@ -348,7 +349,7 @@ export class ConceptPageManager {
await this.app.vault.createFolder(uncategorizedPath);
}
const today = new Date().toISOString().slice(0, 10);
const today = todayIso();
for (const concept of concepts) {
// 检查是否已存在于任何子目录
@ -362,7 +363,7 @@ export class ConceptPageManager {
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`
`---\ntype: concept\nschema_version: ${SCHEMA_VERSION}\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`
);
}
}

View file

@ -0,0 +1,169 @@
import { App, Modal, Setting } from "obsidian";
import { KnowledgeIndexService, IndexEntry } from "../../core/knowledge";
export interface DebtStats {
emptyConcepts: IndexEntry[];
orphanQuestions: IndexEntry[]; // questions with no parent, no related
unfinishedReadings: IndexEntry[]; // reading chapters with status != completed
staleNotes: IndexEntry[]; // notes not modified in >90 days with status=draft
}
/**
* Knowledge Debt Dashboard shows what needs attention in the vault.
*/
export class KnowledgeDebtModal extends Modal {
private stats!: DebtStats;
constructor(
app: App,
private index: KnowledgeIndexService,
private onAction?: (action: string, entries: IndexEntry[]) => void
) {
super(app);
}
onOpen() {
this.titleEl.setText("📊 知识债务看板");
this.stats = this.computeStats();
this.render();
}
private render() {
const { contentEl } = this;
contentEl.empty();
const total =
this.stats.emptyConcepts.length +
this.stats.orphanQuestions.length +
this.stats.unfinishedReadings.length +
this.stats.staleNotes.length;
if (total === 0) {
contentEl.createEl("p", { text: "🎉 你的知识库很健康,没有发现待处理的知识债务。" });
new Setting(contentEl).addButton((btn) => btn.setButtonText("关闭").onClick(() => this.close()));
return;
}
contentEl.createEl("p", {
text: `发现 ${total} 项需要关注的知识债务:`,
attr: { style: "color: var(--text-muted); margin-bottom: 12px;" },
});
// ── Empty concepts ──
this.renderSection(
contentEl,
`📝 空概念页 (${this.stats.emptyConcepts.length})`,
this.stats.emptyConcepts,
"补全这些概念",
"complete-concepts"
);
// ── Orphan questions ──
this.renderSection(
contentEl,
`❓ 孤立问题 (${this.stats.orphanQuestions.length})`,
this.stats.orphanQuestions,
"分类这些问题",
"classify-questions"
);
// ── Unfinished readings ──
this.renderSection(
contentEl,
`📖 未完成阅读章节 (${this.stats.unfinishedReadings.length})`,
this.stats.unfinishedReadings,
null,
null
);
// ── Stale drafts ──
this.renderSection(
contentEl,
`🕸 长期未更新草稿 (${this.stats.staleNotes.length})`,
this.stats.staleNotes,
null,
null
);
new Setting(contentEl).addButton((btn) =>
btn.setButtonText("关闭").onClick(() => this.close())
);
}
private renderSection(
container: HTMLElement,
title: string,
entries: IndexEntry[],
actionLabel: string | null,
actionId: string | null
) {
if (entries.length === 0) return;
const section = container.createDiv({ attr: { style: "margin-bottom: 16px;" } });
section.createEl("h4", { text: title, attr: { style: "margin: 8px 0 4px 0;" } });
const list = section.createDiv({
attr: { style: "max-height: 120px; overflow-y: auto; padding-left: 12px; font-size: 13px;" },
});
const shown = entries.slice(0, 15);
for (const entry of shown) {
list.createEl("div", {
text: `${entry.basename}${entry.domain ? ` [${entry.domain}]` : ""}`,
attr: { style: "padding: 1px 0; color: var(--text-normal);" },
});
}
if (entries.length > 15) {
list.createEl("div", {
text: `...还有 ${entries.length - 15}`,
attr: { style: "color: var(--text-muted); font-style: italic;" },
});
}
if (actionLabel && actionId && this.onAction) {
new Setting(section).addButton((btn) =>
btn.setButtonText(actionLabel).onClick(() => {
this.close();
this.onAction!(actionId, entries);
})
);
}
}
private computeStats(): DebtStats {
const now = Date.now();
const NINETY_DAYS = 90 * 24 * 60 * 60 * 1000;
const emptyConcepts = this.index
.getByType("concept")
.filter((e) => e.status === "empty" || e.status === "pending");
const allQuestions = this.index.getByType("question");
const orphanQuestions = allQuestions.filter((e) => {
// A question is "orphan" if it has no backlinks and no concepts linked
return e.backlinks.length === 0 && e.concepts.length === 0;
});
// Reading chapters: files in "Reading" path with status != completed
const unfinishedReadings = this.index.getAll().filter((e) => {
return (
e.path.includes("Reading/") &&
e.type !== "domain-index" &&
e.status !== "completed" &&
e.headings.some((h) => h.includes("笔记") || h.includes("问题"))
);
});
const staleNotes = this.index.getAll().filter((e) => {
return (
e.status === "draft" &&
now - e.mtime > NINETY_DAYS
);
});
return { emptyConcepts, orphanQuestions, unfinishedReadings, staleNotes };
}
onClose() {
this.contentEl.empty();
}
}

View file

@ -45,8 +45,8 @@ export class QuestionClassifyModal extends Modal {
.addOption("refinement", CATEGORY_LABELS.refinement)
.addOption("expansion", CATEGORY_LABELS.expansion)
.setValue(this.classification.category)
.onChange((v: QuestionCategory) => {
this.classification.category = v;
.onChange((v: string) => {
this.classification.category = v as QuestionCategory;
})
);

View file

@ -1,5 +1,6 @@
import { App, TFile, normalizePath, stringifyYaml } from "obsidian";
import { DeepSeekSettings, QuestionClassification } from "../../types";
import { SCHEMA_VERSION, todayIso } from "../../core/schema";
export interface QuestionMeta {
file: TFile;
@ -38,16 +39,16 @@ export class QuestionGraphManager {
concepts: string[]
): Promise<void> {
const content = await this.app.vault.read(file);
const today = new Date().toISOString().slice(0, 10);
const fm: Record<string, unknown> = {
type: "question",
schema_version: SCHEMA_VERSION,
question,
category: classification.category,
parent: classification.parent ?? null,
related: classification.related,
concepts,
created_at: today,
created_at: todayIso(),
status: "linked",
};

View file

@ -194,7 +194,9 @@ export class ReadingProjectManager {
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 numPrefix = String(ch.number).padStart(2, "0");
const fileName = `${numPrefix}-${this.sanitize(ch.title)}`;
return `- [ ] ${icon}${ch.number}章:[[${fileName}|${ch.title}]]`;
});
const mermaidLines = plan.chapterRelations.length > 0
@ -250,7 +252,9 @@ ${conceptLinks}
}
private async createChapterNote(folder: string, bookTitle: string, chapter: ChapterSkeleton): Promise<void> {
const fileName = this.sanitize(chapter.title);
// Zero-padded chapter number prefix ensures correct sort order in file tree
const numPrefix = String(chapter.number).padStart(2, "0");
const fileName = `${numPrefix}-${this.sanitize(chapter.title)}`;
const filePath = normalizePath(`${folder}/${fileName}.md`);
if (this.app.vault.getAbstractFileByPath(filePath)) return;
@ -303,7 +307,8 @@ ${conceptLinks}
}
private async writeChapterQuestions(folder: string, chapter: ChapterSkeleton, detail: ChapterDetail): Promise<void> {
const fileName = this.sanitize(chapter.title);
const numPrefix = String(chapter.number).padStart(2, "0");
const fileName = `${numPrefix}-${this.sanitize(chapter.title)}`;
const filePath = normalizePath(`${folder}/${fileName}.md`);
const file = this.app.vault.getAbstractFileByPath(filePath);

View file

@ -33,7 +33,7 @@ export class BaiduSyncModal extends Modal {
.addOption("restore", "仅从百度云恢复")
.addOption("force-restore", "⚠️ 强制覆盖本地(以云端为准)")
.setValue(this.mode)
.onChange((v: SyncMode) => { this.mode = v; this.refresh(); })
.onChange((v: string) => { this.mode = v as SyncMode; this.refresh(); })
);
new Setting(contentEl)
@ -56,7 +56,7 @@ export class BaiduSyncModal extends Modal {
.addOption("local", "以本地为准")
.addOption("remote", "以远端为准")
.setValue(this.conflictStrategy)
.onChange((v: ConflictStrategy) => (this.conflictStrategy = v))
.onChange((v: string) => (this.conflictStrategy = v as ConflictStrategy))
);
new Setting(contentEl)

View file

@ -14,12 +14,59 @@ import { SectionAppender } from "./ai/SectionAppender";
import { MarkdownBeautifier } from "./ai/formatter/MarkdownBeautifier";
import { registerAllActions } from "./actions/registry";
import { ALL_ACTIONS } from "./actions/definitions";
import { ConceptCompleter } from "./ai/ConceptCompleter";
import { ConceptPageManager } from "./features/concept/ConceptPageManager";
import { DepthSelectModal, PreviewModal, BatchScanModal } from "./features/concept/ConceptCompletionModal";
import { QuestionClassifier } from "./ai/QuestionClassifier";
import { QuestionGraphManager } from "./features/question/QuestionGraphManager";
import { DeepSeekClient } from "./ai/DeepSeekClient";
import { VaultWriter } from "./vault/VaultWriter";
import { QuestionModal } from "./features/question/QuestionModal";
import { QuestionClassifyModal } from "./features/question/QuestionClassifyModal";
import { KnowledgeDebtModal } from "./features/dashboard/KnowledgeDebtModal";
import { ArtifactFeatureController } from "./features/artifact/ArtifactFeatureController";
import { SCHEMA_VERSION, todayIso } from "./core/schema";
import { KnowledgeIndexService } from "./core/knowledge";
import { PlanBuilder } from "./core/execution";
import { PlanExecutor } from "./core/execution";
import { ScheduledTaskRunner, ScheduledTaskConfig } from "./core/scheduler";
export default class DeepSeekPlugin extends Plugin {
settings: DeepSeekSettings;
settings!: DeepSeekSettings;
/** In-memory vault knowledge index, rebuilt on load, updated incrementally. */
knowledgeIndex!: KnowledgeIndexService;
/** Scheduled task runner — only active while Obsidian is open. */
private scheduler: ScheduledTaskRunner | null = null;
async onload() {
await this.loadSettings();
// Build knowledge index
this.knowledgeIndex = new KnowledgeIndexService(this.app);
this.app.workspace.onLayoutReady(() => {
this.knowledgeIndex.rebuild();
// Scheduler runtime is disabled by default in v2.0.
// Enable via settings once scheduler UI is shipped in v2.1.
// this.startScheduler();
});
// Incremental updates
this.registerEvent(
this.app.metadataCache.on("changed", (file) => {
this.knowledgeIndex.updateFile(file);
})
);
this.registerEvent(
this.app.vault.on("delete", (file) => {
this.knowledgeIndex.removeFile(file.path);
})
);
this.registerEvent(
this.app.vault.on("rename", (file, oldPath) => {
this.knowledgeIndex.removeFile(oldPath);
if (file instanceof TFile) this.knowledgeIndex.updateFile(file);
})
);
this.registerView(SYNC_VIEW_TYPE, (leaf) => new BaiduSyncView(leaf, this));
this.addRibbonIcon("cloud", "Baidu cloud sync", () => { void this.activateSyncView(); });
this.addSettingTab(new DeepSeekSettingsTab(this.app, this));
@ -185,9 +232,18 @@ export default class DeepSeekPlugin extends Plugin {
new Notice(`✅ 已追加到概念页:${name}`);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(existing);
} else if (existing) {
// 路径被非 markdown 文件占用,安全降级:换名
const altPath = await this.findFreePath(uncategorizedPath, name);
const today = todayIso();
const fullContent = `---\ntype: concept\nschema_version: ${SCHEMA_VERSION}\nname: ${name}\nstatus: completed\ncreated_from: ai-assistant\nsource: "[[${sourcePath}]]"\ncreated_at: ${today}\n---\n\n# ${name}\n\n${content}${sourceLink}`;
const file = await this.app.vault.create(altPath, fullContent);
new Notice(`✅ 已创建概念页:${file.basename}(原路径被占用,已自动换名)`);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
} else {
const today = new Date().toISOString().slice(0, 10);
const fullContent = `---\ntype: concept\nname: ${name}\nstatus: completed\ncreated_from: ai-assistant\nsource: "[[${sourcePath}]]"\ncreated_at: ${today}\n---\n\n# ${name}\n\n${content}${sourceLink}`;
const today = todayIso();
const fullContent = `---\ntype: concept\nschema_version: ${SCHEMA_VERSION}\nname: ${name}\nstatus: completed\ncreated_from: ai-assistant\nsource: "[[${sourcePath}]]"\ncreated_at: ${today}\n---\n\n# ${name}\n\n${content}${sourceLink}`;
const file = await this.app.vault.create(filePath, fullContent);
new Notice(`✅ 已创建概念页:${name}(原文已建立链接)`);
const leaf = this.app.workspace.getLeaf(false);
@ -202,6 +258,17 @@ export default class DeepSeekPlugin extends Plugin {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** 找一个未被占用的概念页路径(追加 -2、-3 ... */
private async findFreePath(folder: string, baseName: string): Promise<string> {
let candidate = normalizePath(`${folder}/${baseName}.md`);
let suffix = 2;
while (this.app.vault.getAbstractFileByPath(candidate)) {
candidate = normalizePath(`${folder}/${baseName}-${suffix}.md`);
suffix++;
}
return candidate;
}
/** 扫描内容中の [[双链]],为不存在的概念自动创建页面 */
private async ensureLinkedConcepts(content: string) {
const links = content.match(/\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g);
@ -233,9 +300,9 @@ export default class DeepSeekPlugin extends Plugin {
const filePath = normalizePath(`${uncategorizedPath}/${concept}.md`);
if (this.app.vault.getAbstractFileByPath(filePath)) continue;
const today = new Date().toISOString().slice(0, 10);
const today = todayIso();
await this.app.vault.create(filePath,
`---\ntype: concept\nname: ${concept}\nstatus: empty\ncompletion_status: pending\ncreated_from: ai-assistant\ncreated_at: ${today}\n---\n\n# ${concept}\n\n## 定义\n\n## 核心解释\n\n## 示例\n\n## 关联概念\n\n## 相关问题\n`
`---\ntype: concept\nschema_version: ${SCHEMA_VERSION}\nname: ${concept}\nstatus: empty\ncompletion_status: pending\ncreated_from: ai-assistant\ncreated_at: ${today}\n---\n\n# ${concept}\n\n## 定义\n\n## 核心解释\n\n## 示例\n\n## 关联概念\n\n## 相关问题\n`
);
created++;
}
@ -245,6 +312,289 @@ export default class DeepSeekPlugin extends Plugin {
}
}
// ── 概念页补全 ─────────────────────────────────────────────
/** 补全当前打开的概念页 */
openCompleteCurrentConcept() {
const manager = new ConceptPageManager(this.app, this.settings);
void (async () => {
const info = await manager.analyzeCurrentFile();
if (!info) { new Notice("当前文件不是概念页"); return; }
if (!info.isEmpty) { new Notice(`概念页"${info.conceptName}"已有内容,无需补全`); return; }
new DepthSelectModal(this.app, info.conceptName, (depth) => {
void this.runConceptCompletion(info.file, info.conceptName, depth, {
sourceQuestion: info.sourceQuestion,
sourceAnswer: info.sourceAnswer,
});
}).open();
})();
}
/** 扫描所有空概念页并批量补全 */
openScanEmptyConcepts() {
const manager = new ConceptPageManager(this.app, this.settings);
void (async () => {
const notice = new Notice("⏳ 扫描空概念页...", 0);
const emptyConcepts = await manager.scanEmptyConcepts();
notice.hide();
const items = emptyConcepts.map((c) => ({ name: c.conceptName, path: c.file.path }));
new BatchScanModal(this.app, items, (selectedPaths, depth) => {
void this.batchCompleteConcepts(selectedPaths, depth);
}).open();
})();
}
private async runConceptCompletion(
file: TFile,
conceptName: string,
depth: CompletionDepth,
context: { sourceQuestion?: string; sourceAnswer?: string }
) {
const notice = new Notice(`⏳ 正在补全"${conceptName}"...`, 0);
try {
const completer = new ConceptCompleter(this.settings);
const relatedConcepts = this.getKnownConcepts().filter((c) => c !== conceptName).slice(0, 10);
const result = await completer.complete(conceptName, depth, {
...context,
relatedConcepts,
});
notice.hide();
const manager = new ConceptPageManager(this.app, this.settings);
const previewMd = manager.buildPreviewMarkdown(result, depth);
new PreviewModal(
this.app,
conceptName,
previewMd,
() => {
void (async () => {
await manager.writeCompletion(file, result, depth);
new Notice(`✅ 概念页"${conceptName}"补全完成`);
})();
},
() => { void this.runConceptCompletion(file, conceptName, depth, context); }
).open();
} catch (err) {
notice.hide();
new Notice(`❌ 补全失败:${(err as Error).message}`);
}
}
private async batchCompleteConcepts(paths: string[], depth: CompletionDepth) {
const manager = new ConceptPageManager(this.app, this.settings);
let done = 0;
const total = paths.length;
for (const path of paths) {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) continue;
const info = await manager.analyzeFile(file);
if (!info || !info.isEmpty) continue;
const notice = new Notice(`⏳ (${done + 1}/${total}) 补全"${info.conceptName}"...`, 0);
try {
const completer = new ConceptCompleter(this.settings);
const relatedConcepts = this.getKnownConcepts().filter((c) => c !== info.conceptName).slice(0, 10);
const result = await completer.complete(info.conceptName, depth, {
sourceQuestion: info.sourceQuestion,
sourceAnswer: info.sourceAnswer,
relatedConcepts,
});
await manager.writeCompletion(file, result, depth);
done++;
notice.hide();
} catch (err) {
notice.hide();
new Notice(`❌ "${info.conceptName}"补全失败:${(err as Error).message}`);
}
}
new Notice(`✅ 批量补全完成:${done}/${total}`);
}
// ── 知识提问(带问题图谱) ─────────────────────────────────
/** 提问入口:问题分类 → Q&A 生成 → 图谱更新 */
openQuestionWithGraph() {
new QuestionModal(this.app, (question) => {
void this.askWithGraph(question);
}).open();
}
private async askWithGraph(question: string) {
const notice = new Notice("⏳ AI 思考中...", 0);
try {
// 1. 问题分类
const graphManager = new QuestionGraphManager(this.app, this.settings);
const history = graphManager.getQuestionHistory();
const classifier = new QuestionClassifier(this.settings);
const classification = await classifier.classify(question, history);
notice.hide();
// 2. 让用户确认/修改分类
new QuestionClassifyModal(this.app, question, classification, (finalClassification) => {
void this.generateQAWithGraph(question, finalClassification);
}).open();
} catch (err) {
notice.hide();
new Notice(`${(err as Error).message}`);
}
}
private async generateQAWithGraph(question: string, classification: import("./types").QuestionClassification) {
const notice = new Notice("⏳ 生成 Q&A 笔记...", 0);
try {
// 1. 调用 DeepSeek 获取答案
const client = new DeepSeekClient(this.settings);
const response = await client.ask(question);
// 2. 写入 Q&A 笔记
const writer = new VaultWriter(this.app, this.settings);
const file = await writer.writeQANote(question, response);
// 3. 附加分类 frontmatter
const graphManager = new QuestionGraphManager(this.app, this.settings);
await graphManager.attachClassification(file, question, classification, response.concepts);
// 4. 更新问题索引
await graphManager.updateQuestionIndex(question, classification, file.path);
// 5. 追加推荐问题
await graphManager.appendRecommendations(file, classification);
notice.hide();
new Notice(`✅ 已生成 Q&A${question.slice(0, 30)}...`);
// 打开生成的笔记
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
} catch (err) {
notice.hide();
new Notice(`${(err as Error).message}`);
}
}
// ── 执行资产 Builder ─────────────────────────────────────────
/** 从当前知识生成执行资产(通用入口) */
openArtifactBuilder() {
new ArtifactFeatureController(this.app, this.settings, this.knowledgeIndex)
.openBuilder();
}
// ── 知识债务看板 ─────────────────────────────────────────────
openKnowledgeDebt() {
new KnowledgeDebtModal(this.app, this.knowledgeIndex, (actionId, entries) => {
if (actionId === "complete-concepts") {
const paths = entries.map((e) => e.path).slice(0, 5);
void this.batchCompleteConcepts(paths, "standard");
}
// future: classify-questions
}).open();
}
// ── 知识库问答(带来源引用) ─────────────────────────────────
/**
* "Ask your vault" uses KnowledgeIndex to find relevant notes,
* then sends them as context so the AI can answer with source references.
*/
openVaultQA() {
const editor = this.app.workspace.activeEditor?.editor ?? null;
const selection = editor?.getSelection().trim() ?? "";
const activeFile = this.app.workspace.getActiveFile();
const contextHint = selection
? `📎 已选中 ${selection.length}`
: activeFile
? `📄 ${activeFile.basename}`
: "";
new AssistantInputModal(this.app, `[知识库问答] ${contextHint}`, (instruction) => {
void this.runVaultQA(instruction, selection, activeFile);
}).open();
}
private async runVaultQA(question: string, selection: string, activeFile: TFile | null) {
const notice = new Notice("⏳ 检索知识库并生成回答...", 0);
const currentEditor = this.app.workspace.activeEditor?.editor ?? null;
try {
// 1. Search index for relevant entries
const results = this.knowledgeIndex.search(question, {
limit: 8,
contextFile: activeFile?.path,
});
// 2. Build context from retrieved entries
const contextParts: string[] = [];
const sourceFiles: { path: string; title: string }[] = [];
for (const { entry } of results) {
const file = this.app.vault.getAbstractFileByPath(entry.path);
if (!file || !(file instanceof TFile)) continue;
const content = await this.app.vault.cachedRead(file);
// Take first 600 chars per file to keep context manageable
const snippet = content.slice(0, 600).trim();
contextParts.push(`--- 来源:[[${entry.path}|${entry.title}]] (${entry.type ?? "note"}) ---\n${snippet}`);
sourceFiles.push({ path: entry.path, title: entry.title });
}
const knowledgeContext = contextParts.join("\n\n");
// 3. Build prompt that instructs citing sources
const systemPrompt = `你是一个基于用户个人知识库的问答助手。以下是从用户知识库中检索到的相关笔记片段。请基于这些内容回答问题,并在回答中引用来源(使用 [[笔记名]] 双链格式)。
${knowledgeContext}
${selection ? `用户当前选中的文字:\n${selection}\n` : ""}`;
const userPrompt = question;
// 4. Call AI
const { LLMClient } = await import("./core/llm");
const llm = new LLMClient(this.settings);
const raw = await llm.chat({ systemPrompt, userPrompt, temperature: 0.5 });
// 5. Post-process: beautify + auto-link
const knownConcepts = this.getKnownConcepts();
const style = this.settings.outputStyle ?? "knowledge-base";
const assistant = new AIAssistant(this.settings, style, knownConcepts);
const beautified = assistant.beautifyContent(raw);
// 6. Append source section
const sourcesSection = sourceFiles.length > 0
? `\n\n---\n\n## 依据来源\n\n${sourceFiles.map((s) => `- [[${s.path}|${s.title}]]`).join("\n")}\n`
: "";
const finalContent = beautified + sourcesSection;
notice.hide();
new AssistantResultModal(
this.app,
{ mode: "show", content: finalContent, explanation: `知识库问答(引用 ${sourceFiles.length} 篇笔记)` },
() => {
if (!currentEditor) { new Notice("无法写入:编辑器不可用"); return; }
const cursor = currentEditor.getCursor();
currentEditor.replaceRange("\n" + finalContent + "\n", cursor);
new Notice("✅ 已插入");
},
() => { void this.runVaultQA(question, selection, activeFile); }
).open();
} catch (err) {
notice.hide();
new Notice(`${(err as Error).message}`);
}
}
// ── 阅读项目 ───────────────────────────────────────────────
openNewReadingProject() {
@ -341,6 +691,249 @@ export default class DeepSeekPlugin extends Plugin {
.map((f) => f.basename);
}
// ── 执行模块入口 ───────────────────────────────────────────────
/** 打开待确认计划列表 */
openPendingPlans() {
const { PlanDraftStore } = require("./core/execution") as { PlanDraftStore: new (app: import("obsidian").App) => import("./core/execution").PlanDraftStore };
const store = new PlanDraftStore(this.app);
const files = store.getPendingPlans();
if (files.length === 0) {
new Notice("暂无待确认计划");
return;
}
const leaf = this.app.workspace.getLeaf(false);
void leaf.openFile(files[0]);
if (files.length > 1) {
new Notice(`${files.length} 个待确认计划,已打开最新`);
}
}
/** 打开执行日志列表 */
openExecutionLogs() {
const folder = "Knowledge/_Executions";
const files = this.app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folder + "/"));
if (files.length === 0) {
new Notice("暂无执行日志");
return;
}
const sorted = files.sort((a, b) => b.stat.mtime - a.stat.mtime);
const leaf = this.app.workspace.getLeaf(false);
void leaf.openFile(sorted[0]);
if (files.length > 1) {
new Notice(`${files.length} 条执行记录,已打开最新`);
}
}
/** 确认并执行当前打开的待确认计划 */
async confirmAndExecutePlan() {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) { new Notice("请先打开一个待确认计划文件"); return; }
const meta = this.app.metadataCache.getFileCache(activeFile);
const fm = meta?.frontmatter;
if (fm?.type !== "execution-plan" || fm?.status !== "pending") {
new Notice("当前文件不是待确认计划(需要 type: execution-plan, status: pending");
return;
}
const planId = fm.plan_id as string;
if (!planId) { new Notice("计划文件缺少 plan_id"); return; }
// Try to get plan from cache
const { PlanDraftStore, PlanExecutor } = await import("./core/execution");
const store = new PlanDraftStore(this.app);
const plan = store.getPlan(planId);
if (!plan) {
new Notice("该计划的执行数据已过期Obsidian 重启后缓存会丢失)。请重新生成计划。");
return;
}
// Confirm
const riskLabel = plan.riskLevel === "high" ? "高风险" : plan.riskLevel === "medium" ? "中风险" : "低风险";
const confirmed = await this.confirmAction(
`确认执行「${plan.title}」?\n\n${plan.operations.length} 项操作,${riskLabel},将影响 ${new Set(plan.operations.map(op => "path" in op ? (op as {path:string}).path : (op as {from:string}).from)).size} 个文件。`
);
if (!confirmed) return;
const notice = new Notice("⏳ 正在执行计划...", 0);
const executor = new PlanExecutor(this.app);
const record = await executor.execute(plan);
notice.hide();
if (record.success) {
await store.markExecuted(activeFile);
new Notice(`✅ 计划执行成功:${record.affectedPaths.length} 个文件已更新`);
} else {
new Notice(`❌ 执行失败:${record.error ?? "未知错误"}`);
}
}
/** Simple confirmation dialog using Obsidian Modal. */
private confirmAction(message: string): Promise<boolean> {
return new Promise((resolve) => {
const { Modal, Setting } = require("obsidian") as typeof import("obsidian");
const modal = new Modal(this.app);
modal.titleEl.setText("确认执行");
modal.contentEl.createEl("p", { text: message, attr: { style: "white-space: pre-wrap;" } });
new Setting(modal.contentEl)
.addButton((btn) => btn.setButtonText("确认执行").setCta().onClick(() => { modal.close(); resolve(true); }))
.addButton((btn) => btn.setButtonText("取消").onClick(() => { modal.close(); resolve(false); }));
modal.open();
});
}
/** 查看定时任务状态 */
openScheduledTasks() {
new Notice("定时任务运行时在 v2.0 默认关闭,将在 v2.1 通过设置页启用。");
}
/** 从当前笔记生成执行计划 */
openGeneratePlan() {
const editor = this.app.workspace.activeEditor?.editor ?? null;
const activeFile = this.app.workspace.getActiveFile();
if (!editor || !activeFile) {
new Notice("请先打开一个文件");
return;
}
const content = editor.getValue();
if (!content.trim()) {
new Notice("文档为空");
return;
}
new AssistantInputModal(this.app, `📋 从当前笔记生成执行计划`, (instruction) => {
void this.runGeneratePlan(instruction, content, activeFile);
}).open();
}
private async runGeneratePlan(instruction: string, noteContent: string, sourceFile: TFile) {
const notice = new Notice("⏳ AI 正在生成执行计划...", 0);
try {
const { LLMClient } = await import("./core/llm");
const llm = new LLMClient(this.settings);
const systemPrompt = `你是一个执行计划生成助手。用户会给你一篇笔记和一条指令,你需要从中提取行动项,生成一份丰满、结构化、可直接执行的计划文档。
1. Markdown
2. >
3. checkbox
- ##
- ## 2-3
- ## - [ ]
-
-
- ##
- ##
- ##
4. "推进项目""完成 API 接口文档并发送给前端 review"
5. "待确认"
6. JSON Markdown `;
const userPrompt = `来源笔记:${sourceFile.basename}\n\n笔记内容\n${noteContent.slice(0, 3000)}\n\n用户指令${instruction || "从这篇笔记提取行动项,生成一份完整的执行计划"}`;
const raw = await llm.chat({ systemPrompt, userPrompt, temperature: 0.4 });
notice.hide();
if (!raw.trim()) {
new Notice("AI 未能生成执行计划");
return;
}
// Beautify with known concepts
const knownConcepts = this.getKnownConcepts();
const style = this.settings.outputStyle ?? "knowledge-base";
const assistant = new AIAssistant(this.settings, style, knownConcepts);
const beautified = assistant.beautifyContent(raw);
// Build the plan note
const today = todayIso();
const title = instruction
? instruction.slice(0, 40)
: `${sourceFile.basename} 执行计划`;
const planContent = `---
type: plan
schema_version: 1
source: "[[${sourceFile.path}|${sourceFile.basename}]]"
status: active
created_at: ${today}
---
# ${title}
${beautified}
---
##
- [[${sourceFile.path}|${sourceFile.basename}]]
- ${today}
`;
// Show preview then save
new AssistantResultModal(
this.app,
{ mode: "show", content: planContent, explanation: "执行计划预览" },
() => { void this.savePlanNote(title, planContent); },
() => { void this.runGeneratePlan(instruction, noteContent, sourceFile); }
).open();
} catch (err) {
notice.hide();
new Notice(`${(err as Error).message}`);
}
}
private async savePlanNote(title: string, content: string) {
const folder = normalizePath("Knowledge/Plans");
if (!this.app.vault.getAbstractFileByPath(folder)) {
await this.app.vault.createFolder(folder);
}
const safeName = title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 50);
const today = todayIso();
let path = normalizePath(`${folder}/${today}-${safeName}.md`);
let suffix = 2;
while (this.app.vault.getAbstractFileByPath(path)) {
path = normalizePath(`${folder}/${today}-${safeName}-${suffix}.md`);
suffix++;
}
const file = await this.app.vault.create(path, content);
new Notice(`✅ 执行计划已保存`);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
}
// ── 定时任务 ─────────────────────────────────────────────────
private startScheduler(): void {
const defaultTasks: ScheduledTaskConfig[] = [
{
id: "daily-debt-scan",
name: "每日知识债务扫描",
enabled: false, // user must opt-in via settings
kind: "knowledge-debt-scan",
trigger: { type: "daily", time: "22:00" },
safety: "notify-only",
},
{
id: "daily-baidu-config-sync",
name: "每日百度配置同步",
enabled: this.settings.baiduSync.enabled && this.settings.baiduSync.autoBackup,
kind: "baidu-backup",
trigger: { type: "daily", time: "23:00" },
safety: "auto-execute-low-risk",
},
];
this.scheduler = new ScheduledTaskRunner(this, defaultTasks);
this.scheduler.start();
}
// ── 设置 ───────────────────────────────────────────────────
async loadSettings() {

View file

@ -1,9 +1,13 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
import type DeepSeekPlugin from "../main";
import type { DeepSeekSettings } from "../types";
import { BaiduAuthModal } from "../features/sync/BaiduAuthModal";
type SettingsSection = "knowledge" | "execution" | "auxiliary";
export class DeepSeekSettingsTab extends PluginSettingTab {
private activeSection: SettingsSection = "auxiliary"; // start with AI setup
constructor(app: App, private plugin: DeepSeekPlugin) {
super(app, plugin);
}
@ -11,54 +15,190 @@ export class DeepSeekSettingsTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName("DeepSeek Knowledge Graph 设置").setHeading();
containerEl.addClass("istart-settings-root");
new Setting(containerEl)
.setName("API key")
.setDesc("DeepSeek API Key在 platform.deepseek.com 获取)")
.addText((text) =>
text
.setPlaceholder("sk-...")
// ── Header ───────────────────────────────────────────────
containerEl.createEl("h2", { text: "IStart-Note-AI" });
containerEl.createEl("p", {
text: "知识沉淀 · 执行计划 · 同步辅助",
attr: { style: "color: var(--text-muted); margin-top: -8px; margin-bottom: 16px;" },
});
// ── Layout ───────────────────────────────────────────────
const layout = containerEl.createDiv({ cls: "istart-settings-layout" });
const sidebar = layout.createDiv({ cls: "istart-settings-sidebar" });
const content = layout.createDiv({ cls: "istart-settings-content" });
this.renderNav(sidebar);
this.renderSection(content);
}
private renderNav(container: HTMLElement): void {
const sections: { id: SettingsSection; label: string }[] = [
{ id: "knowledge", label: "知识" },
{ id: "execution", label: "执行" },
{ id: "auxiliary", label: "辅助" },
];
for (const sec of sections) {
const item = container.createDiv({
cls: `istart-settings-nav-item${this.activeSection === sec.id ? " is-active" : ""}`,
});
item.setText(sec.label);
item.addEventListener("click", () => {
this.activeSection = sec.id;
this.display();
});
}
}
private renderSection(container: HTMLElement): void {
switch (this.activeSection) {
case "knowledge":
this.renderKnowledge(container);
break;
case "execution":
this.renderExecution(container);
break;
case "auxiliary":
this.renderAuxiliary(container);
break;
}
}
// ══════════════════════════════════════════════════════════════
// 知识
// ══════════════════════════════════════════════════════════════
private renderKnowledge(el: HTMLElement): void {
new Setting(el).setName("知识路径").setHeading();
new Setting(el)
.setName("Q&A 笔记目录")
.addText((t) =>
t.setPlaceholder("Knowledge/Q&A").setValue(this.plugin.settings.savePath).onChange(async (v) => {
this.plugin.settings.savePath = v.trim() || "Knowledge/Q&A";
await this.plugin.saveSettings();
})
);
new Setting(el)
.setName("问题索引目录")
.addText((t) =>
t.setPlaceholder("Knowledge/Questions").setValue(this.plugin.settings.questionsIndexPath).onChange(async (v) => {
this.plugin.settings.questionsIndexPath = v.trim() || "Knowledge/Questions";
await this.plugin.saveSettings();
})
);
new Setting(el)
.setName("概念页目录")
.addText((t) =>
t.setPlaceholder("Knowledge/Concepts").setValue(this.plugin.settings.conceptsPath).onChange(async (v) => {
this.plugin.settings.conceptsPath = v.trim() || "Knowledge/Concepts";
await this.plugin.saveSettings();
})
);
// ── 知识索引 ──────────────────────────────────────────────
new Setting(el).setName("知识索引").setHeading();
const indexSize = this.plugin.knowledgeIndex?.size ?? 0;
new Setting(el)
.setName("索引状态")
.setDesc(`已索引 ${indexSize} 篇笔记`)
.addButton((btn) =>
btn.setButtonText("重建索引").onClick(() => {
this.plugin.knowledgeIndex.rebuild();
new Notice(`✅ 索引已重建:${this.plugin.knowledgeIndex.size}`);
this.display();
})
);
new Setting(el)
.setName("生成笔记后自动打开图谱")
.addToggle((t) =>
t.setValue(this.plugin.settings.autoOpenGraph).onChange(async (v) => {
this.plugin.settings.autoOpenGraph = v;
await this.plugin.saveSettings();
})
);
}
// ══════════════════════════════════════════════════════════════
// 执行
// ══════════════════════════════════════════════════════════════
private renderExecution(el: HTMLElement): void {
new Setting(el).setName("执行计划").setHeading();
el.createEl("p", {
text: "执行模块当前为实验阶段。所有 AI 生成的写入操作都会先生成计划草稿,需要你确认后再执行。回滚功能尚未实现。定时任务运行时默认关闭,将在 v2.1 启用。",
attr: { style: "color: var(--text-muted); font-size: 13px; margin-bottom: 12px;" },
});
new Setting(el)
.setName("执行日志目录")
.setDesc("每次执行计划后自动生成日志")
.addText((t) =>
t.setPlaceholder("Knowledge/_Executions").setValue("Knowledge/_Executions").setDisabled(true)
);
new Setting(el).setName("定时任务").setHeading();
el.createEl("p", {
text: "定时任务运行时在 v2.0 默认关闭。基础设施已就绪(知识债务扫描、配置同步),将在 v2.1 通过设置启用。",
attr: { style: "color: var(--text-muted); font-size: 13px;" },
});
}
// ══════════════════════════════════════════════════════════════
// 辅助
// ══════════════════════════════════════════════════════════════
private renderAuxiliary(el: HTMLElement): void {
// ── AI 服务 ───────────────────────────────────────────────
new Setting(el).setName("AI 服务").setHeading();
new Setting(el)
.setName("API Key")
.setDesc("DeepSeek 或其他 OpenAI 兼容服务的密钥")
.addText((t) => {
t.setPlaceholder("sk-...")
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.apiKey = value.trim();
.onChange(async (v) => {
this.plugin.settings.apiKey = v.trim();
await this.plugin.saveSettings();
})
);
});
t.inputEl.type = "password";
return t;
});
new Setting(containerEl)
new Setting(el)
.setName("Base URL")
.setDesc("API 地址,默认 https://api.deepseek.com")
.addText((text) =>
text
.setPlaceholder("https://api.deepseek.com")
.setValue(this.plugin.settings.baseUrl)
.onChange(async (value) => {
this.plugin.settings.baseUrl = value.trim() || "https://api.deepseek.com";
await this.plugin.saveSettings();
})
.setDesc("Chat completions 端点根地址")
.addText((t) =>
t.setPlaceholder("https://api.deepseek.com").setValue(this.plugin.settings.baseUrl).onChange(async (v) => {
this.plugin.settings.baseUrl = v.trim() || "https://api.deepseek.com";
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
new Setting(el)
.setName("模型")
.setDesc("选择使用的 DeepSeek 模型")
.addDropdown((drop) =>
drop
.addOption("deepseek-v4-flash", "deepseek-v4-flash快速推荐")
.addDropdown((d) =>
d.addOption("deepseek-v4-flash", "deepseek-v4-flash快速")
.addOption("deepseek-v4-pro", "deepseek-v4-pro深度推理")
.setValue(this.plugin.settings.model)
.onChange(async (value: "deepseek-v4-flash" | "deepseek-v4-pro") => {
this.plugin.settings.model = value;
.onChange(async (v: string) => {
this.plugin.settings.model = v as "deepseek-v4-flash" | "deepseek-v4-pro";
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
new Setting(el)
.setName("输出风格")
.setDesc("AI 生成内容的默认风格")
.addDropdown((drop) =>
drop
.addOption("knowledge-base", "知识库(推荐)")
.addDropdown((d) =>
d.addOption("knowledge-base", "知识库(推荐)")
.addOption("technical", "技术文档")
.addOption("minimal", "极简")
.addOption("product", "产品设计")
@ -66,67 +206,16 @@ export class DeepSeekSettingsTab extends PluginSettingTab {
.addOption("story", "世界观/叙事")
.addOption("dashboard", "卡片化")
.setValue(this.plugin.settings.outputStyle)
.onChange(async (value: string) => {
this.plugin.settings.outputStyle = value as DeepSeekSettings["outputStyle"];
.onChange(async (v: string) => {
this.plugin.settings.outputStyle = v as DeepSeekSettings["outputStyle"];
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("笔记保存路径")
.setDesc("Q&A 笔记存储目录(相对于 Vault 根目录)")
.addText((text) =>
text
.setPlaceholder("Knowledge/Q&A")
.setValue(this.plugin.settings.savePath)
.onChange(async (value) => {
this.plugin.settings.savePath = value.trim() || "Knowledge/Q&A";
await this.plugin.saveSettings();
})
);
// ── 百度网盘同步 ──────────────────────────────────────────
new Setting(el).setName("百度网盘同步").setHeading();
new Setting(containerEl)
.setName("问题索引路径")
.setDesc("问题图谱索引页存储目录")
.addText((text) =>
text
.setPlaceholder("Knowledge/Questions")
.setValue(this.plugin.settings.questionsIndexPath)
.onChange(async (value) => {
this.plugin.settings.questionsIndexPath = value.trim() || "Knowledge/Questions";
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("概念页保存路径")
.setDesc("概念页存储目录(相对于 Vault 根目录)")
.addText((text) =>
text
.setPlaceholder("Knowledge/Concepts")
.setValue(this.plugin.settings.conceptsPath)
.onChange(async (value) => {
this.plugin.settings.conceptsPath = value.trim() || "Knowledge/Concepts";
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("自动打开 Graph view")
.setDesc("生成笔记后自动打开图谱视图")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoOpenGraph)
.onChange(async (value) => {
this.plugin.settings.autoOpenGraph = value;
await this.plugin.saveSettings();
})
);
// ── 百度云同步 ──────────────────────────────────────────
new Setting(containerEl).setName("百度网盘同步").setHeading();
new Setting(containerEl)
new Setting(el)
.setName("启用百度云同步")
.addToggle((t) =>
t.setValue(this.plugin.settings.baiduSync.enabled).onChange(async (v) => {
@ -137,142 +226,100 @@ export class DeepSeekSettingsTab extends PluginSettingTab {
);
if (this.plugin.settings.baiduSync.enabled) {
new Setting(containerEl)
.setName("App ID")
.setDesc("百度开放平台应用的 App Key")
.addText((text) =>
text
.setPlaceholder("your-app-id")
.setValue(this.plugin.settings.baiduSync.appId)
.onChange(async (v) => {
this.plugin.settings.baiduSync.appId = v.trim();
await this.plugin.saveSettings();
})
);
this.renderBaiduSyncSettings(el);
}
new Setting(containerEl)
.setName("App secret")
.setDesc("百度开放平台应用的 Secret Key")
.addText((text) => {
text
.setPlaceholder("your-app-secret")
.setValue(this.plugin.settings.baiduSync.appSecret)
.onChange((v) => {
this.plugin.settings.baiduSync.appSecret = v.trim();
void this.plugin.saveSettings();
});
text.inputEl.type = "password";
return text;
// ── 隐私与诊断 ───────────────────────────────────────────
new Setting(el).setName("隐私与诊断").setHeading();
el.createEl("p", {
text: "AI 功能会把选中内容和部分上下文发送到所配置的 API 端点。同步功能会把笔记上传到你自己的百度网盘。完整说明见 PRIVACY.md。",
attr: { style: "color: var(--text-muted); font-size: 13px;" },
});
}
private renderBaiduSyncSettings(el: HTMLElement): void {
const cfg = this.plugin.settings.baiduSync;
new Setting(el)
.setName("App ID")
.addText((t) =>
t.setPlaceholder("your-app-id").setValue(cfg.appId).onChange(async (v) => {
cfg.appId = v.trim();
await this.plugin.saveSettings();
})
);
new Setting(el)
.setName("App Secret")
.addText((t) => {
t.setPlaceholder("your-app-secret").setValue(cfg.appSecret).onChange((v) => {
cfg.appSecret = v.trim();
void this.plugin.saveSettings();
});
const tokenStatus = this.plugin.settings.baiduSync.accessToken
? `已授权(过期时间:${this.plugin.settings.baiduSync.tokenExpiresAt?.slice(0, 10) ?? "未知"}`
: "未授权";
new Setting(containerEl)
.setName("授权状态")
.setDesc(tokenStatus)
.addButton((btn) =>
btn.setButtonText("重新授权").onClick(() => {
new BaiduAuthModal(
this.app,
this.plugin.settings.baiduSync,
(accessToken, refreshToken, expiresAt) => {
this.plugin.settings.baiduSync.accessToken = accessToken;
this.plugin.settings.baiduSync.refreshToken = refreshToken;
this.plugin.settings.baiduSync.tokenExpiresAt = expiresAt;
void this.plugin.saveSettings().then(() => this.display());
}
).open();
})
);
new Setting(containerEl)
.setName("远端备份路径")
.setDesc("百度网盘中的备份根目录")
.addText((text) =>
text
.setPlaceholder("/apps/istart-note-ai")
.setValue(this.plugin.settings.baiduSync.remotePath)
.onChange(async (v) => {
this.plugin.settings.baiduSync.remotePath = v.trim() || "/apps/istart-note-ai";
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("自动备份")
.setDesc("每次生成笔记后自动备份到百度云")
.addToggle((t) =>
t.setValue(this.plugin.settings.baiduSync.autoBackup).onChange(async (v) => {
this.plugin.settings.baiduSync.autoBackup = v;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("备份插件本身")
.setDesc("备份时包含插件文件main.js、配置等方便换设备恢复")
.addToggle((t) =>
t.setValue(this.plugin.settings.baiduSync.backupPlugin).onChange(async (v) => {
this.plugin.settings.baiduSync.backupPlugin = v;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("忽略规则")
.setDesc("正则表达式,匹配的文件路径将被跳过")
.addText((text) =>
text
.setPlaceholder("node_modules|.git")
.setValue(this.plugin.settings.baiduSync.ignorePattern)
.onChange(async (v) => {
this.plugin.settings.baiduSync.ignorePattern = v.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("单文件大小限制MB")
.addText((text) =>
text
.setPlaceholder("100")
.setValue(String(this.plugin.settings.baiduSync.fileSizeLimitMB))
.onChange(async (v) => {
const n = parseInt(v);
if (!isNaN(n) && n > 0) {
this.plugin.settings.baiduSync.fileSizeLimitMB = n;
await this.plugin.saveSettings();
}
})
);
// 配置同步
new Setting(containerEl).setName("配置同步").setHeading();
containerEl.createEl("p", {
text: "将路径、模型等偏好设置同步到百度云,在多台设备间共享(不含 API Key 和 Token 等凭证)。",
cls: "istart-settings-config-hint",
t.inputEl.type = "password";
return t;
});
new Setting(containerEl)
.setName("推送配置到百度云")
.setDesc("将当前设置上传,其他设备可拉取同步")
.addButton((btn) =>
btn.setButtonText("推送").onClick(async () => {
await this.plugin.pushConfig();
})
);
const tokenStatus = cfg.accessToken
? `已授权(过期:${cfg.tokenExpiresAt?.slice(0, 10) ?? "未知"}`
: "未授权";
new Setting(containerEl)
.setName("从百度云拉取配置")
.setDesc("拉取最新配置并应用(不覆盖凭证)")
.addButton((btn) =>
btn.setButtonText("拉取").onClick(async () => {
await this.plugin.pullConfig();
this.display();
})
);
}
new Setting(el)
.setName("授权状态")
.setDesc(tokenStatus)
.addButton((btn) =>
btn.setButtonText("授权").onClick(() => {
new BaiduAuthModal(this.app, cfg, (accessToken, refreshToken, expiresAt) => {
cfg.accessToken = accessToken;
cfg.refreshToken = refreshToken;
cfg.tokenExpiresAt = expiresAt;
void this.plugin.saveSettings().then(() => this.display());
}).open();
})
);
new Setting(el)
.setName("远端路径")
.addText((t) =>
t.setPlaceholder("/apps/istart-note-ai").setValue(cfg.remotePath).onChange(async (v) => {
cfg.remotePath = v.trim() || "/apps/istart-note-ai";
await this.plugin.saveSettings();
})
);
new Setting(el)
.setName("自动备份")
.setDesc("生成笔记后自动备份")
.addToggle((t) => t.setValue(cfg.autoBackup).onChange(async (v) => { cfg.autoBackup = v; await this.plugin.saveSettings(); }));
new Setting(el)
.setName("备份插件本身")
.addToggle((t) => t.setValue(cfg.backupPlugin).onChange(async (v) => { cfg.backupPlugin = v; await this.plugin.saveSettings(); }));
new Setting(el)
.setName("忽略规则")
.setDesc("正则表达式")
.addText((t) =>
t.setPlaceholder("node_modules|.git").setValue(cfg.ignorePattern).onChange(async (v) => {
cfg.ignorePattern = v.trim();
await this.plugin.saveSettings();
})
);
new Setting(el)
.setName("单文件大小限制MB")
.addText((t) =>
t.setPlaceholder("100").setValue(String(cfg.fileSizeLimitMB)).onChange(async (v) => {
const n = parseInt(v);
if (!isNaN(n) && n > 0) { cfg.fileSizeLimitMB = n; await this.plugin.saveSettings(); }
})
);
// 配置同步
new Setting(el).setName("跨设备配置同步").setDesc("不含凭证").setHeading();
new Setting(el)
.addButton((btn) => btn.setButtonText("推送配置").onClick(() => void this.plugin.pushConfig()))
.addButton((btn) => btn.setButtonText("拉取配置").onClick(async () => { await this.plugin.pullConfig(); this.display(); }));
}
}

View file

@ -1,5 +1,6 @@
import { App, TFile, normalizePath } from "obsidian";
import { DeepSeekResponse, DeepSeekSettings, ContextQAInput, ContextQAResponse } from "../types";
import { SCHEMA_VERSION, todayIso } from "../core/schema";
export class VaultWriter {
constructor(private app: App, private settings: DeepSeekSettings) {}
@ -7,12 +8,11 @@ export class VaultWriter {
async writeQANote(question: string, response: DeepSeekResponse): Promise<TFile> {
const date = new Date().toISOString().slice(0, 10);
const safeTitle = this.sanitizeFilename(question).slice(0, 50);
const filename = `${date}-${safeTitle}.md`;
const folderPath = normalizePath(this.settings.savePath);
const filePath = normalizePath(`${folderPath}/${filename}`);
await this.ensureFolder(folderPath);
const filePath = await this.uniqueFilePath(folderPath, `${date}-${safeTitle}`);
const content = this.buildNoteContent(question, response);
const file = await this.app.vault.create(filePath, content);
@ -26,12 +26,11 @@ export class VaultWriter {
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 filePath = await this.uniqueFilePath(folderPath, `${date}-ctx-${safeTitle}`);
const content = this.buildContextNoteContent(input, response);
const file = await this.app.vault.create(filePath, content);
@ -144,9 +143,10 @@ ${tagLine || "暂无标签"}
const filePath = normalizePath(`${uncategorizedPath}/${concept}.md`);
const exists = this.app.vault.getAbstractFileByPath(filePath);
if (!exists) {
const today = new Date().toISOString().slice(0, 10);
const today = todayIso();
const content = `---
type: concept
schema_version: ${SCHEMA_VERSION}
name: ${concept}
status: empty
completion_status: pending
@ -179,6 +179,21 @@ created_at: ${today}
}
}
/**
* Resolve a non-conflicting `.md` file path inside `folderPath`.
* Appends `-2`, `-3`, ... when a file with the same name already exists.
*/
private async uniqueFilePath(folderPath: string, baseName: string): Promise<string> {
const safeBase = baseName || "note";
let candidate = normalizePath(`${folderPath}/${safeBase}.md`);
let suffix = 2;
while (this.app.vault.getAbstractFileByPath(candidate)) {
candidate = normalizePath(`${folderPath}/${safeBase}-${suffix}.md`);
suffix++;
}
return candidate;
}
private sanitizeFilename(name: string): string {
return name.replace(/[\\/:*?"<>|#[\]]/g, "-").trim();
}

View file

@ -1,5 +1,62 @@
/* IStart-Note-AI Plugin Styles */
/* ── Settings Page Layout ────────────────────────── */
.istart-settings-layout {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 20px;
}
.istart-settings-sidebar {
border-right: 1px solid var(--background-modifier-border);
padding-right: 12px;
}
.istart-settings-content {
min-width: 0;
}
.istart-settings-nav-item {
display: block;
width: 100%;
padding: 8px 10px;
border-radius: 6px;
color: var(--text-normal);
cursor: pointer;
margin-bottom: 4px;
font-size: 14px;
}
.istart-settings-nav-item:hover {
background: var(--background-modifier-hover);
}
.istart-settings-nav-item.is-active {
background: var(--background-modifier-hover);
color: var(--text-accent);
font-weight: 600;
}
@media (max-width: 700px) {
.istart-settings-layout {
display: block;
}
.istart-settings-sidebar {
border-right: none;
border-bottom: 1px solid var(--background-modifier-border);
margin-bottom: 12px;
padding-bottom: 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.istart-settings-nav-item {
display: inline-block;
width: auto;
}
}
/* ── Baidu Sync View ─────────────────────────────── */
.istart-sync-root {
@ -395,6 +452,70 @@
border-color: var(--interactive-accent);
}
/* ── Result Modal (mobile-safe flex layout) ──────── */
.istart-result-modal {
display: flex;
flex-direction: column;
max-height: 80vh;
}
.istart-result-preview {
flex: 1 1 auto;
overflow-y: auto;
min-height: 80px;
max-height: 55vh;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
background: var(--background-secondary);
}
.istart-result-mode-hint {
flex-shrink: 0;
font-size: 12px;
color: var(--text-muted);
margin: 0 0 8px 0;
}
.istart-result-actions {
flex-shrink: 0;
border-top: 1px solid var(--background-modifier-border);
padding-top: 8px;
}
.istart-result-actions .setting-item {
border: none;
padding: 4px 0;
}
@media (max-width: 600px) {
.istart-result-modal {
max-height: 85vh;
}
.istart-result-preview {
max-height: 40vh;
}
.istart-result-actions .setting-item {
flex-wrap: wrap;
gap: 6px;
}
.istart-result-actions .setting-item-control {
width: 100%;
display: flex;
gap: 6px;
}
.istart-result-actions .setting-item-control button {
flex: 1;
min-width: 0;
}
}
.istart-assistant-preview {
max-height: 50vh;
overflow-y: auto;

View file

@ -9,8 +9,15 @@
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"alwaysStrict": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"lib": ["ES6", "DOM"]
"noFallthroughCasesInSwitch": true,
"lib": ["ES2018", "DOM"]
},
"include": ["**/*.ts"]
"include": ["src/**/*.ts"]
}

View file

@ -22,5 +22,7 @@
"1.8.0": "1.7.2",
"1.8.1": "1.7.2",
"1.8.2": "1.7.2",
"1.8.3": "1.7.2"
"1.8.3": "1.7.2",
"1.9.0": "1.7.2",
"2.0.0": "1.7.2"
}