├── src ├── index.scss ├── types │ ├── onlyThis.d.ts │ ├── index.d.ts │ ├── sytypes.d.ts │ ├── mcp.ts │ ├── siyuan.ts │ └── api.d.ts ├── tools │ ├── baseToolProvider.ts │ ├── sharedFunction.ts │ ├── time.ts │ ├── sql.ts │ ├── docWrite.ts │ ├── search.ts │ ├── vectorSearch.ts │ ├── attributes.ts │ ├── relation.ts │ ├── docRead.ts │ ├── dailynote.ts │ ├── blockWrite.ts │ └── flashCard.ts ├── constants.ts ├── utils │ ├── wsMainHelper.ts │ ├── pluginHelper.ts │ ├── stringUtils.ts │ ├── indexerHelper.ts │ ├── mutex.ts │ ├── mcpResponse.ts │ ├── lang.ts │ ├── crypto.ts │ ├── filterCheck.ts │ ├── fakeEncrypt.ts │ ├── resultFilter.ts │ ├── queue.ts │ ├── commonCheck.ts │ ├── eventHandler.ts │ └── historyTaskHelper.ts ├── indexer │ ├── baseIndexProvider.ts │ ├── indexConsumer.ts │ ├── myProvider.ts │ └── index.ts ├── audit │ └── auditRedoer.ts ├── logger │ └── index.ts ├── i18n │ ├── zh_CN.json │ └── en_US.json ├── syapi │ └── custom.ts └── components │ └── history.vue ├── .eslintignore ├── icon.png ├── preview.png ├── .gitignore ├── tsconfig.node.json ├── scripts ├── .release.py └── reset_dev_loc.js ├── RAG_BETA.md ├── static ├── prompt_dynamic_query_system_CN.md ├── query_syntax.md ├── prompt_create_cards_system_CN.md └── database_schema.md ├── tsconfig.json ├── .eslintrc.js ├── plugin.json ├── package.json ├── .github ├── workflows │ └── release.yml └── ISSUE_TEMPLATE │ ├── bug_report_zh_cn.yml │ └── bug_report.yml ├── CHANGELOG.md ├── TOOL_INFO.md ├── README_zh_CN.md ├── webpack.config.js └── README.md /src/index.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | index.js 4 | -------------------------------------------------------------------------------- /src/types/onlyThis.d.ts: -------------------------------------------------------------------------------- 1 | interface QueueDocIdItem { 2 | id: string; 3 | } -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpaqueGlass/syplugin-anMCPServer/main/icon.png -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpaqueGlass/syplugin-anMCPServer/main/preview.png -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.md" { 2 | const content: string; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /src/tools/baseToolProvider.ts: -------------------------------------------------------------------------------- 1 | export abstract class McpToolsProvider { 2 | abstract getTools(): Promise[]>; 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | .DS_Store 4 | .eslintcache 5 | dist 6 | package.zip 7 | index.css 8 | index.js 9 | scripts/devInfo.json 10 | dev/ -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export class CONSTANTS { 2 | public static readonly STORAGE_NAME: string = "setting.json"; 3 | public static readonly CODE_UNSET: string = "N/A"; 4 | } -------------------------------------------------------------------------------- /src/utils/wsMainHelper.ts: -------------------------------------------------------------------------------- 1 | import { MyDelayQueue } from "./queue"; 2 | 3 | const wsIndexQueue = new MyDelayQueue(7); 4 | 5 | export function useWsIndexQueue() { 6 | return wsIndexQueue; 7 | } -------------------------------------------------------------------------------- /src/indexer/baseIndexProvider.ts: -------------------------------------------------------------------------------- 1 | export abstract class IndexProvider { 2 | abstract update(id: string, content: string): Promise; 3 | abstract delete(id: string): Promise; 4 | abstract query(queryText: string): Promise; 5 | abstract health(): Promise; 6 | }; -------------------------------------------------------------------------------- /src/utils/pluginHelper.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "siyuan"; 2 | 3 | let pluginInstance: Plugin = null; 4 | 5 | export function setPluginInstance(instance:Plugin) { 6 | pluginInstance = instance; 7 | } 8 | export function getPluginInstance(): any { 9 | return pluginInstance; 10 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "webpack.config.js" 11 | ] 12 | } -------------------------------------------------------------------------------- /scripts/.release.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | with open('CHANGELOG.md', 'r', encoding='utf-8') as f: 4 | readme_str = f.read() 5 | 6 | match_obj = re.search(r'(?<=### )[\s\S]*?(?=#)', readme_str, re.DOTALL) 7 | if match_obj: 8 | h3_title = match_obj.group(0) 9 | with open('result.txt', 'w') as f: 10 | f.write(h3_title) 11 | else: 12 | with open('result.txt', 'w') as f: 13 | f.write("") -------------------------------------------------------------------------------- /src/utils/stringUtils.ts: -------------------------------------------------------------------------------- 1 | export function htmlTransferParser(inputStr) { 2 | if (inputStr == null || inputStr == "") return ""; 3 | let transfer = ["<", ">", " ", """, "&"]; 4 | let original = ["<", ">", " ", `"`, "&"]; 5 | for (let i = 0; i < transfer.length; i++) { 6 | inputStr = inputStr.replace(new RegExp(transfer[i], "g"), original[i]); 7 | } 8 | return inputStr; 9 | } -------------------------------------------------------------------------------- /RAG_BETA.md: -------------------------------------------------------------------------------- 1 | 插件现在支持开发者自定义的RAG-server API格式。你可以直接使用开发者提供的Demo,或基于Demo修改、以支持更多RAG实现。 2 | 3 | 1. (获取Demo)请切换到本仓库的`rag-server`分支; 4 | 2. 克隆代码,并参考其中的`README.md`完成环境配置和启动服务; 5 | 3. 在启动本插件提供的MCP服务之前,应当先启动RAG后端,否则插件会判断未使用后端服务、不在UI、MCP Tools中显示RAG; 6 | 1. 在文档树上右键一个或一些文档,菜单中选择“插件”“对所选文档进行索引”或“对所选文档及其下层文档进行索引”; 7 | 2. 插件不会检测文档变化并推送到MCP服务,也不会主动推送未被要求索引的文档; 8 | 3. 请避免一次推送大量文档,这会影响思源的性能,也可能导致RAG后端崩溃; 9 | 4. 如遇到问题,请在 [#2](https://github.com/OpaqueGlass/syplugin-anMCPServer/issues/2) 中提出; -------------------------------------------------------------------------------- /src/audit/auditRedoer.ts: -------------------------------------------------------------------------------- 1 | import { updateBlockAPI } from "@/syapi"; 2 | 3 | export async function auditRedo(taskItem: any) { 4 | const { 5 | id, 6 | modifiedIds, 7 | content, 8 | taskType, 9 | args, 10 | status, 11 | createdAt, 12 | updatedAt 13 | } = taskItem; 14 | switch (taskType) { 15 | case "updateBlock": { 16 | const response = await updateBlockAPI(content, modifiedIds[0]); 17 | break; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/utils/indexerHelper.ts: -------------------------------------------------------------------------------- 1 | import { CacheQueue } from "@/indexer"; 2 | import { IndexProvider } from "@/indexer/baseIndexProvider"; 3 | import { IndexConsumer } from "@/indexer/indexConsumer"; 4 | import { MyIndexProvider } from "@/indexer/myProvider"; 5 | 6 | let provider: IndexProvider; 7 | let indexer: CacheQueue = new CacheQueue("data/storage/petal/syplugin-anMCPServer"); 8 | 9 | let indexConsumer = new IndexConsumer(); 10 | 11 | export function useQueue():CacheQueue { 12 | return indexer; 13 | } 14 | 15 | export function useProvider(): IndexProvider { 16 | return provider; 17 | } 18 | 19 | export function useConsumer(): IndexConsumer { 20 | return indexConsumer; 21 | } 22 | 23 | export function setIndexProvider(ip) { 24 | provider = ip; 25 | } -------------------------------------------------------------------------------- /static/prompt_dynamic_query_system_CN.md: -------------------------------------------------------------------------------- 1 | 你是一名 **思源笔记助手**,能够通过工具操作和 SQL 查询来检索和分析笔记,以自然语言形式回答用户问题。 2 | 3 | ### 工具使用规则: 4 | 1. **结构探索** 5 | - 任何检索前,先使用 `siyuan_database_schema` 工具,理解数据库的表结构和字段含义。 6 | - 关于通过SQL检索日记等特殊笔记的方式,也需要使用`siyuan_database_schema` 工具。 7 | 8 | 2. **SQL 检索** 9 | - 使用 `siyuan_query_sql` 工具执行 SQL 查询。 10 | - 时间处理: 11 | - 若用户给出相对时间(如“过去一年”“最近X天”),请使用 SQLite 内置日期时间函数自动转换。 12 | - 若用户给出具体日期或时间戳,直接使用即可。 13 | 14 | 3. **特殊规则** 15 | - 当 `type='d'` 时,`content` 字段表示文档名称,而不是文档内容。 16 | - 要阅读文档内容,还需要使用 `siyuan_read_doc_content_markdown` 工具; 17 | 18 | 4. **总结与分析** 19 | - 如果用户的需求涉及总结、归纳、提炼,需读取相关文档或检索到的内容块,再进行处理。 20 | - 保证输出结果以自然语言呈现,不暴露 SQL 细节。 21 | 22 | 5. **输出规范** 23 | - 默认只输出思考总结过程和最终结果(如列表、总结、统计信息)。 24 | - SQL 查询和操作细节只在出现错误、需要用户协助时才展示。 25 | 26 | ### 角色定位: 27 | 你是一位 **智能化的思源笔记检索与分析助手**,能够灵活选择工具,高效检索并生成用户所需的结果。 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // "noImplicitAny": true, 4 | "module": "commonjs", 5 | "target": "es6", 6 | "strict": false, 7 | "noUnusedLocals": true, 8 | "noUnusedParameters": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "allowSyntheticDefaultImports": true, 11 | "paths": { 12 | "@/*": ["./src/*"], 13 | }, 14 | "lib": [ 15 | "ES2021", //ES2020.String不支持.replaceAll 16 | "DOM", 17 | "DOM.Iterable", 18 | ], 19 | "types": [ 20 | "zod", 21 | "siyuan", 22 | "vue" 23 | ], 24 | "typeRoots": ["./src/types"] 25 | }, 26 | "include": [ 27 | "src/**/*.ts", 28 | "src/**/*.d.ts", 29 | "static/*.md", 30 | "src/**/*.vue" 31 | ], 32 | "references": [ 33 | { 34 | "path": "./tsconfig.node.json" 35 | } 36 | ], 37 | "root": ".", 38 | "skipLibCheck": true, 39 | 40 | } 41 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: {node: true, browser: true, es6: true}, 4 | parser: "@typescript-eslint/parser", 5 | plugins: [ 6 | "@typescript-eslint", 7 | ], 8 | extends: [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | ], 12 | rules: { 13 | semi: [2, "always"], 14 | quotes: [2, "double", {"avoidEscape": true}], 15 | "no-async-promise-executor": "off", 16 | "no-prototype-builtins": "off", 17 | "no-useless-escape": "off", 18 | "no-irregular-whitespace": "off", 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "@typescript-eslint/no-var-requires": "off", 21 | "@typescript-eslint/explicit-function-return-type": "off", 22 | "@typescript-eslint/explicit-module-boundary-types": "off", 23 | "@typescript-eslint/no-explicit-any": "off", 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syplugin-anMCPServer", 3 | "author": "OpaqueGlass", 4 | "url": "https://github.com/OpaqueGlass/syplugin-anMCPServer", 5 | "version": "0.6.0", 6 | "minAppVersion": "2.9.0", 7 | "disabledInPublish": true, 8 | "backends": [ 9 | "windows", 10 | "linux", 11 | "darwin" 12 | ], 13 | "frontends": [ 14 | "desktop" 15 | ], 16 | "displayName": { 17 | "default": "A MCP server for Siyuan", 18 | "zh_CN": "这是一个MCP服务端插件" 19 | }, 20 | "description": { 21 | "default": "Start a MCP server in siyuan", 22 | "zh_CN": "在思源中启动MCP服务" 23 | }, 24 | "readme": { 25 | "default": "README.md", 26 | "zh_CN": "README_zh_CN.md" 27 | }, 28 | "funding": { 29 | "openCollective": "", 30 | "patreon": "", 31 | "github": "", 32 | "custom": [ 33 | "https://wj.qq.com/s2/12395364/b69f/" 34 | ] 35 | }, 36 | "keywords": [ 37 | "model context protocol" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/mutex.ts: -------------------------------------------------------------------------------- 1 | export default class Mutex { 2 | private isLocked: boolean = false; 3 | private queue: (() => void)[] = []; 4 | 5 | async lock(): Promise { 6 | return new Promise((resolve) => { 7 | const acquireLock = async () => { 8 | if (!this.isLocked) { 9 | this.isLocked = true; 10 | resolve(); 11 | } else { 12 | this.queue.push(() => { 13 | this.isLocked = true; 14 | resolve(); 15 | }); 16 | } 17 | }; 18 | 19 | acquireLock(); 20 | }); 21 | } 22 | 23 | tryLock(): boolean { 24 | if (!this.isLocked) { 25 | this.isLocked = true; 26 | return true; 27 | } else { 28 | return false; 29 | } 30 | } 31 | 32 | unlock(): void { 33 | this.isLocked = false; 34 | const next = this.queue.shift(); 35 | if (next) { 36 | next(); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/utils/mcpResponse.ts: -------------------------------------------------------------------------------- 1 | import { array } from "zod"; 2 | 3 | /** 4 | * Success response helper 5 | */ 6 | export function createSuccessResponse(text: string, metadata?: Record): McpResponse { 7 | return { 8 | content: [{ 9 | type: "text", 10 | text 11 | }], 12 | _meta: metadata 13 | }; 14 | } 15 | 16 | /** 17 | * JSON response helper 18 | */ 19 | export function createJsonResponse(data: any, otherData: any[]|null=null): McpResponse { 20 | if (Array.isArray(data)) { 21 | data = { result: data }; 22 | } 23 | const result: McpContent[] = [{ 24 | type: "text", 25 | text: JSON.stringify(data, null, 2) 26 | } as McpContent]; 27 | if (otherData != null) { 28 | result.push(...otherData as McpContent[]); 29 | } 30 | return { 31 | content: result, 32 | structuredContent: data, 33 | }; 34 | } 35 | 36 | /** 37 | * Error response helper 38 | */ 39 | export function createErrorResponse(errorMessage: string): McpResponse { 40 | return { 41 | content: [{ 42 | type: "text", 43 | text: errorMessage 44 | }], 45 | isError: true 46 | }; 47 | } -------------------------------------------------------------------------------- /src/utils/lang.ts: -------------------------------------------------------------------------------- 1 | let language = null; 2 | let emptyLanguageKey: Array = []; 3 | 4 | export function setLanguage(lang:any) { 5 | language = lang; 6 | } 7 | 8 | export function lang(key: string) { 9 | if (language != null && language[key] != null) { 10 | return language[key]; 11 | } else { 12 | emptyLanguageKey.push(key); 13 | console.error("语言文件未定义该Key", JSON.stringify(emptyLanguageKey)); 14 | } 15 | return key; 16 | } 17 | 18 | /** 19 | * 20 | * @param key key 21 | * @returns [设置项名称,设置项描述,设置项按钮名称(如果有)] 22 | */ 23 | export function settingLang(key: string) { 24 | let settingName: string = lang(`setting_${key}_name`); 25 | let settingDesc: string = lang(`setting_${key}_desp`); 26 | let settingBtnName: string = lang(`setting_${key}_btn`) 27 | if (settingName == "Undefined" || settingDesc == "Undefined") { 28 | throw new Error(`设置文本${key}未定义`); 29 | } 30 | return [settingName, settingDesc, settingBtnName]; 31 | } 32 | 33 | export function settingPageLang(key: string) { 34 | let pageSettingName: string = lang(`settingpage_${key}_name`); 35 | return [pageSettingName]; 36 | } -------------------------------------------------------------------------------- /src/tools/sharedFunction.ts: -------------------------------------------------------------------------------- 1 | import { createDocWithPath } from "@/syapi"; 2 | import { checkIdValid, getDocDBitem } from "@/syapi/custom"; 3 | import { isValidNotebookId, isValidStr } from "@/utils/commonCheck"; 4 | 5 | export async function createNewDocWithParentId(parentId:string, title:string, markdownContent: string) { 6 | checkIdValid(parentId); 7 | // 判断是否是笔记本id 8 | const notebookIdFlag = isValidNotebookId(parentId); 9 | const newDocId = window.Lute.NewNodeID(); 10 | const createParams = { 11 | "notebook": parentId, 12 | "path": `/${newDocId}.sy`, "title": title, "md": markdownContent, "listDocTree": false }; 13 | if (!isValidStr(title)) createParams["title"] = "Untitled"; 14 | if (!notebookIdFlag) { 15 | // 判断是否是笔记id 16 | const docInfo = await getDocDBitem(parentId); 17 | if (docInfo == null) { 18 | throw new Error("无效的输入参数`parentId`,parentId应当对应笔记本id或文档id,请检查输入的id参数"); 19 | } 20 | createParams["path"] = docInfo["path"].replace(".sy", "") + createParams["path"]; 21 | createParams["notebook"] = docInfo["box"]; 22 | } 23 | // 创建 24 | const result = await createDocWithPath(createParams["notebook"], createParams["path"], createParams["title"], createParams["md"]); 25 | return {result, newDocId}; 26 | } -------------------------------------------------------------------------------- /src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import { logPush } from "@/logger"; 2 | import { getPluginInstance } from "./pluginHelper"; 3 | 4 | export async function calculateSHA256(fileOrString) { 5 | // 如果是字符串,先转成 ArrayBuffer 6 | let data; 7 | if (typeof fileOrString === 'string') { 8 | const encoder = new TextEncoder(); 9 | data = encoder.encode(fileOrString); 10 | } else if (fileOrString instanceof Blob) { 11 | data = await fileOrString.arrayBuffer(); 12 | } else { 13 | throw new Error('Unsupported input type'); 14 | } 15 | 16 | // 计算哈希 17 | const hashBuffer = await crypto.subtle.digest('SHA-256', data); 18 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 19 | const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); 20 | return hashHex; 21 | } 22 | 23 | export async function isAuthTokenValid(inputCode:string) { 24 | inputCode += window?.siyuan?.config?.system?.id ?? "glass"; 25 | inputCode = await calculateSHA256(inputCode); 26 | const plugin = getPluginInstance(); 27 | if (plugin?.mySettings["authCode"] === inputCode) { 28 | return true; 29 | } else { 30 | return false; 31 | } 32 | } 33 | 34 | export async function encryptAuthCode(inputCode:string) { 35 | inputCode += window?.siyuan?.config?.system?.id ?? "glass"; 36 | return await calculateSHA256(inputCode); 37 | } -------------------------------------------------------------------------------- /static/query_syntax.md: -------------------------------------------------------------------------------- 1 | 在使用查询语法之前,我们先大致了解一下该语法的构成: 2 | 3 | * 字符串(String):由字母、数字等构成 4 | * 短语(Phrase):由字符串构成 5 | * 查询(Query):由短语构成,多个查询可通过 `AND`、`OR` 和 `NOT` 组合为新的查询 6 | 7 | 下面我们分别介绍它们的细节。 8 | 9 | ## 字符串 10 | 11 | 字符串可由两种方式指定: 12 | 13 | * 通过英文双引号 `"` 包裹起来的字符。其中如果需要使用 `"` 本身,则可通过 SQL 风格转义(再加一个`"`),例如 `"foo""bar"""` 将搜索命中 `foo"bar"` 14 | * 由非 `AND`、`OR` 和 `NOT` 字符组成,并且这些字符必须是: 15 | 16 | * 所有非 ASCII 字符,或者 17 | * 属于 52 个大小写英文字符(`A-Za-z`),或者 18 | * 属于 10 个 ASCII 数字字符(`0-9`),或者 19 | * 是下划线 `_`,或者 20 | * 是替换符(ASCII 26) 21 | 22 | 对于非上述的其他字符构成的字符串,必须使用 `"` 包裹起来,比如包含了 `-`、`*` 等符号的字符串。 23 | 24 | ## 短语 25 | 26 | 短语由字符串构成,可由 `+` 进行连接。一个短语是由一些记号(Token)有序构成,这些记号由用户的输入文本通过分词器处理得到。思源使用的分词器为了让中文搜索好用(支持单字搜索),所以实现方式是按照字分词的,也就是说每个汉字或者英文字母都会被拆分为一个记号。这对 `+` 连接会产生一些影响,所以如果不确定的话,建议不要使用 `+` 组合多个短语。 27 | 28 | ## 查询 29 | 30 | 查询由多个短语构成,可通过操作符 `AND`、`OR` 和 `NOT` 组合为新的查询。 31 | 32 | | 操作符 | 功能 | 33 | | -------- | ------------------------------- | 34 | | | query1 和 query2 同时匹配 | 35 | | | query1 或者 query2 匹配 | 36 | | | query1 匹配同时 query2 不匹配 | 37 | 38 | 使用英文圆括号 `()` 可以组合查询的优先级,例如: 39 | 40 | ```sql 41 | -- 匹配包含 "one" 或者 "two" 的块,不匹配包含 "three" 的块 42 | 'one OR two NOT three' 43 | 44 | -- 匹配包含 "one" 或者包含 "two" 且不包含 "three" 的块 45 | 'one OR (two NOT three)' 46 | ``` 47 | 48 | 使用空格分隔的多个短语默认使用 `AND` 连接,比如: 49 | 50 | ```sql 51 | 'one two three' -- 'one AND two AND three' 52 | 'three "one two"' -- 'three AND "one two"' 53 | 'one OR two three' -- 'one OR two AND three' 54 | 55 | '(one OR two) three' -- 语法错误! 56 | 'func(one two)' -- 语法错误! 57 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syplugin-a-mcp-server", 3 | "version": "0.1.0", 4 | "description": "A mcp server for Siyuan note", 5 | "main": ".src/index.js", 6 | "scripts": { 7 | "lint": "eslint . --fix --cache", 8 | "dev": "webpack --mode development", 9 | "build": "webpack --mode production", 10 | "change-dir": "node --no-warnings ./scripts/reset_dev_loc.js" 11 | }, 12 | "keywords": [], 13 | "author": "OpaqueGlass", 14 | "license": "AGPL-3.0", 15 | "devDependencies": { 16 | "@types/express": "^5.0.1", 17 | "@types/node": "^22.15.3", 18 | "@typescript-eslint/eslint-plugin": "8.42.0", 19 | "@typescript-eslint/parser": "8.42.0", 20 | "copy-webpack-plugin": "^11.0.0", 21 | "css-loader": "^6.7.1", 22 | "esbuild-loader": "^4.3.0", 23 | "eslint": "^9.35.0", 24 | "mini-css-extract-plugin": "2.3.0", 25 | "raw-loader": "^4.0.2", 26 | "sass": "^1.62.1", 27 | "sass-loader": "^12.6.0", 28 | "siyuan": "1.1.1", 29 | "style-loader": "^4.0.0", 30 | "tslib": "2.4.0", 31 | "typescript": "5.9.2", 32 | "webpack": "^5.76.0", 33 | "webpack-cli": "^5.0.2", 34 | "zip-webpack-plugin": "^4.0.1" 35 | }, 36 | "dependencies": { 37 | "@modelcontextprotocol/sdk": "^1.17.4", 38 | "@vue/tsconfig": "^0.8.1", 39 | "async-mutex": "^0.5.0", 40 | "element-plus": "^2.11.2", 41 | "express": "^5.1.0", 42 | "ts-loader": "^9.5.4", 43 | "v-code-diff": "^1.13.1", 44 | "vue": "^3.5.21", 45 | "vue-loader": "^17.4.2", 46 | "vue-tsc": "^3.0.6", 47 | "zod": "^3.24.3" 48 | }, 49 | "pnpm": { 50 | "onlyBuiltDependencies": [ 51 | "v-code-diff" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/filterCheck.ts: -------------------------------------------------------------------------------- 1 | import { getBlockDBItem } from "@/syapi/custom"; 2 | import { getPluginInstance } from "./pluginHelper"; 3 | import { logPush } from "@/logger"; 4 | 5 | function getPluginSettings() { 6 | const plugin = getPluginInstance(); 7 | return plugin?.mySettings; 8 | } 9 | 10 | 11 | export async function filterBlock(blockId: string, dbItem: any|null): Promise { 12 | const settings = getPluginSettings(); 13 | const filterNotebooks = settings?.filterNotebooks.split("\n").map(id => id.trim()).filter(id => id); 14 | const filterDocuments = settings?.filterDocuments.split("\n").map(id => id.trim()).filter(id => id); 15 | if (!dbItem) { 16 | dbItem = await getBlockDBItem(blockId); 17 | } 18 | logPush("Checking", dbItem); 19 | if (dbItem) { 20 | const notebookId = dbItem.box; 21 | const path = dbItem.path; 22 | if (filterNotebooks && filterNotebooks.includes(notebookId)) { 23 | return true; 24 | } 25 | if (filterDocuments) { 26 | for (const docId of filterDocuments) { 27 | if (notebookId === docId || path.includes(docId) || dbItem.id === docId) { 28 | return true; 29 | } 30 | } 31 | } 32 | } 33 | return false; 34 | } 35 | 36 | export function filterNotebook(notebookId: string): boolean { 37 | const settings = getPluginSettings(); 38 | const filterNotebooks = settings?.filterNotebooks.split("\n").map(id => id.trim()).filter(id => id); 39 | logPush("Checking", settings, filterNotebooks); 40 | if (filterNotebooks && filterNotebooks.includes(notebookId)) { 41 | return true; 42 | } 43 | return false; 44 | } 45 | -------------------------------------------------------------------------------- /static/prompt_create_cards_system_CN.md: -------------------------------------------------------------------------------- 1 | 你是一个**MCP抽认卡生成助手(Flashcard Generator via MCP)**,任务是帮助用户根据提供的内容或对话上下文生成抽认卡。你将根据用户的需求,提供不同的抽认卡形式,并通过MCP工具 `siyuan_create_flashcards_with_new_doc` 创建最终的抽认卡。 2 | 3 | ### 流程步骤: 4 | 5 | #### 1. 话题选择 6 | - 用户发送你设计的提示词后,可能会指定一个话题。 7 | - 根据用户的需求,你将询问一些问题来明确出题方式和目标: 8 | 9 | #### 2. 确认任务类型 10 | - 询问用户以下选择之一: 11 | - **生成一些问题:** 你会根据用户提供的内容,生成一些相关问题,作为抽认卡的题目。 12 | - **检索笔记并生成问题:** 用户提供一段文本或笔记,要求你从中提取关键信息并生成问题。 13 | - **用户提供了内容并生成问题:** 用户可能提供一段完整内容,要求你基于此内容生成问题。 14 | 15 | #### 3. 确定出题方式 16 | - 向用户询问**生成问题的方式**,有两种常见的题目形式: 17 | 1. **QA形式:** 提问是一个标题,答案是这个标题下的内容,格式如下: 18 | - 示例: 19 | ``` 20 | #### 什么是MCP协议? 21 | MCP(Model Context Protocol)是用于规范大型语言模型与工具或数据源之间交互方式的协议。 22 | ``` 23 | 2. **高亮标记填空题:** 将段落中的关键信息(如术语、定义等)标记为 `==术语==` 形式,其他部分为填空内容,格式如下: 24 | - 示例: 25 | ``` 26 | MCP 是由 ==Anthropic== 推出的开源协议,用于实现 LLM 与外部数据源之间的安全集成。 27 | ``` 28 | 29 | #### 4. 卡片设计要求 30 | - 每张卡片聚焦**一个核心知识点**。 31 | - 语言要**清晰、具体、简洁**,避免模糊表达。 32 | - 问题不能含糊,答案必须是**唯一的事实/术语/定义**。 33 | - 答案应该只包含一个**关键的事实/名称/概念/术语**。 34 | - **QA卡片**:提问是一个标题,答案是该标题下面的内容,使用Markdown四级标题 `#### 问题` 。 35 | - **挖空卡片**:在段落中的关键信息以 `==术语==` 形式标记,其他部分为空白供填充。 36 | 37 | #### 5. 生成题目并确认 38 | - 基于用户提供的内容或任务要求,生成一系列问题,并向用户确认: 39 | - “这是你需要的问题吗?需要进行修改吗?” 40 | - 确保问题的格式、语言和内容符合用户的要求。 41 | - 包含生成闪卡的新笔记保存在哪里?如果用户模糊提供了文档名称或笔记本名称,你可能需要通过其他工具获取对应的id,作为工具的parentId提供; 42 | 43 | #### 6. 使用工具创建抽认卡 44 | - 确认无误后,使用MCP工具 `siyuan_create_flashcards_with_new_doc` 来生成卡片。 45 | - 工具调用格式: 46 | ```json 47 | { 48 | "parentId": "请传入创建闪卡文档所在的parent doc id或笔记本id", 49 | "docTitle": "根据用户需求自动生成标题", 50 | "type": "h4", // 或 highlight,根据用户选择 51 | "deckId": "可选,若用户提供", 52 | "markdownContent": "生成的卡片内容,符合Markdown格式" 53 | } 54 | 55 | -------------------------------------------------------------------------------- /src/logger/index.ts: -------------------------------------------------------------------------------- 1 | 2 | // debug push 3 | let g_DEBUG = 2; 4 | const g_NAME = "mcp"; 5 | const g_FULLNAME = "MCP_Server"; 6 | 7 | /* 8 | LEVEL 0 忽略所有 9 | LEVEL 1 仅Error 10 | LEVEL 2 Err + Warn 11 | LEVEL 3 Err + Warn + Info 12 | LEVEL 4 Err + Warn + Info + Log 13 | LEVEL 5 Err + Warn + Info + Log + Debug 14 | 请注意,基于代码片段加入window下的debug设置,可能在刚载入挂件时无效 15 | */ 16 | export function commonPushCheck() { 17 | if (window.top["OpaqueGlassDebugV2"] == undefined || window.top["OpaqueGlassDebugV2"][g_NAME] == undefined) { 18 | return g_DEBUG; 19 | } 20 | return window.top["OpaqueGlassDebugV2"][g_NAME]; 21 | } 22 | 23 | export function isDebugMode() { 24 | return commonPushCheck() > g_DEBUG; 25 | } 26 | 27 | export function debugPush(str: string, ...args: any[]) { 28 | if (commonPushCheck() >= 5) { 29 | console.debug(`${g_FULLNAME}[D] ${new Date().toLocaleTimeString()} ${str}`, ...args); 30 | } 31 | } 32 | 33 | export function infoPush(str: string, ...args: any[]) { 34 | if (commonPushCheck() >= 3) { 35 | console.info(`${g_FULLNAME}[I] ${new Date().toLocaleTimeString()} ${str}`, ...args); 36 | } 37 | } 38 | 39 | export function logPush(str: string, ...args: any[]) { 40 | if (commonPushCheck() >= 4) { 41 | console.log(`${g_FULLNAME}[L] ${new Date().toLocaleTimeString()} ${str}`, ...args); 42 | } 43 | } 44 | 45 | export function errorPush(str: string, ... args: any[]) { 46 | if (commonPushCheck() >= 1) { 47 | console.error(`${g_FULLNAME}[E] ${new Date().toLocaleTimeString()} ${str}`, ...args); 48 | } 49 | } 50 | 51 | export function warnPush(str: string, ... args: any[]) { 52 | if (commonPushCheck() >= 2) { 53 | console.warn(`${g_FULLNAME}[W] ${new Date().toLocaleTimeString()} ${str}`, ...args); 54 | } 55 | } -------------------------------------------------------------------------------- /src/tools/time.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createJsonResponse } from "../utils/mcpResponse"; 3 | import { McpToolsProvider } from "./baseToolProvider"; 4 | import { lang } from "@/utils/lang"; 5 | 6 | export class TimeToolProvider extends McpToolsProvider { 7 | async getTools(): Promise[]> { 8 | return [{ 9 | name: "get_current_time", 10 | description: lang("tool_get_current_time"), 11 | schema: {}, 12 | handler: getCurrentTimeHandler, 13 | title: lang("tool_title_get_current_time"), 14 | annotations: { 15 | readOnlyHint: true, 16 | } 17 | }]; 18 | } 19 | } 20 | 21 | async function getCurrentTimeHandler(params, extra) { 22 | const now = new Date(); 23 | const year = now.getFullYear(); 24 | const month = (now.getMonth() + 1).toString().padStart(2, '0'); 25 | const day = now.getDate().toString().padStart(2, '0'); 26 | const hours = now.getHours().toString().padStart(2, '0'); 27 | const minutes = now.getMinutes().toString().padStart(2, '0'); 28 | const seconds = now.getSeconds().toString().padStart(2, '0'); 29 | const dayOfWeek = now.toLocaleString('en-US', { weekday: 'long' }); 30 | 31 | const timeInfo = { 32 | iso: now.toISOString(), 33 | year: year, 34 | month: month, 35 | day: day, 36 | hour: hours, 37 | minute: minutes, 38 | second: seconds, 39 | dayOfWeek: dayOfWeek, 40 | formattedDate: `${year}-${month}-${day}`, 41 | formattedTime: `${hours}:${minutes}:${seconds}`, 42 | formattedDateTime: `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`, 43 | timezoneOffset: now.getTimezoneOffset(), 44 | unixTimestamp: Math.floor(now.getTime() / 1000), 45 | }; 46 | 47 | return createJsonResponse(timeInfo); 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release on Tag Push 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | # Checkout 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | 19 | # Install Node.js 20 | - name: Install Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 18 24 | registry-url: "https://registry.npmjs.org" 25 | 26 | # Install pnpm 27 | - name: Install pnpm 28 | uses: pnpm/action-setup@v4 29 | id: pnpm-install 30 | with: 31 | version: 8 32 | run_install: false 33 | 34 | # Get pnpm store directory 35 | - name: Get pnpm store directory 36 | id: pnpm-cache 37 | shell: bash 38 | run: | 39 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 40 | 41 | # Setup pnpm cache 42 | - name: Setup pnpm cache 43 | uses: actions/cache@v3 44 | with: 45 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 46 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 47 | restore-keys: | 48 | ${{ runner.os }}-pnpm-store- 49 | 50 | # Install dependencies 51 | - name: Install dependencies 52 | run: pnpm install 53 | 54 | # Build for production, 这一步会生成一个 package.zip 55 | - name: Build for production 56 | run: pnpm build 57 | 58 | - name: Set up Python 59 | uses: actions/setup-python@v2 60 | with: 61 | python-version: '3.8' 62 | 63 | - name: Get CHANGELOGS 64 | run: python ./scripts/.release.py 65 | 66 | - name: Release 67 | uses: softprops/action-gh-release@v1 68 | with: 69 | body_path: ./result.txt 70 | files: package.zip 71 | prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') || contains(github.ref, 'dev') }} 72 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 更新日志 | CHANGELOG 2 | 3 | ### v0.6.0 (2025-11-01) 4 | 5 | - 新增:设置排除笔记本ID或文档ID,在部分工具避免返回敏感内容; 6 | - 请注意此功能并非严格排除:由于实现方式的问题,对部分工具或部分情况并不过滤; 7 | - 修复:`insertBlock`API传入参数非`parentID`时未被校验的问题; 8 | - 修复:检查id时可能被sql注入的问题; 9 | 10 | ### v0.5.1 (2025-09-08) 11 | 12 | - 修复:历史记录页面 点击“完整内容”后界面错乱的问题; 13 | - 改进:历史记录页面 块不存在时的提示信息; 14 | - 修复:历史记录页面 块点击跳转文档打开后显示不完整的问题; 15 | - 改进:appendBlock 工具返回值调整为第一个doOperation; 16 | 17 | ### v0.5.0 (2025-09-08) 18 | 19 | - 新增:块编辑API;(插入块*3、更新块、块kramdown) 20 | - 新增:审计日志;(新增或修改类型的工具调用将记录操作结果,但不记录更新前内容) 21 | - 块编辑工具默认等待用户批准后更新; 22 | - 改进:gutSubDocIds -> listSubDocs; 23 | 24 | ### v0.4.0 (2025-08-31) 25 | 26 | - 修复:升级依赖版本,使工具可以被并发调用; 27 | - 改进:调整思源数据库结构提示词; 28 | - 新增:新增部分工具; 29 | - 闪卡、属性; 30 | - 移除:移除文本搜索工具; 31 | - 新增:只读模式; 32 | 33 | ### v0.3.1 (2025年7月25日) 34 | 35 | - 修复:(RAG)不能索引下层文档的问题; 36 | - 新增:(工具)传送Markdown内容,按照指定的方式制卡; 37 | - 新增:(提示词参考)调用制卡工具的系统提示词; 38 | 39 | ### v0.3.0 (2025年7月13日) 40 | 41 | - 新增:(工具)和后端通信的知识库问答API; 42 | - 修复:工具获取块内容错误文档标题的问题; 43 | 44 | ### v0.2.1 (2025年6月30日) 45 | 46 | - 改进:在日记不存在时,由MCP创建的日记移除开头空行; 47 | - 新增:工具 48 | - 在任意位置创建新文档; 49 | - 获取反向链接; 50 | - 改进:工具获取块内容现在支持返回图片、音频附件(出于接收方能力考虑,有大小限制); 51 | 52 | ### v0.2.0 (2025年6月15日) 53 | 54 | - 改进:支持Streamable HTTP连接方式,对应有端点更改;原有SSE连接方式标记为弃用,**请参考文档重新设置**; 55 | - 改进:不同设备可以使用不同的配置文件,这意味着升级到此版本后原有的配置将丢失; 56 | - 新增:支持设置访问授权码; 57 | 58 | 59 | - Improvement: Added support for Streamable HTTP connection method, with corresponding endpoint changes; the original SSE connection method is marked as deprecated. **Please reconfigure by referring to the documentation**; 60 | - Improvement: Different devices can now use different configuration files. This means that upgrading to this version will cause the loss of previous configurations; 61 | - New Feature: Added support for setting access authorization codes; 62 | 63 | ### v0.1.2 (2025年5月20日) 64 | 65 | - 修复:搜索类型限制设置无效的问题; 66 | - 改进:获取文档内容支持分页,默认1万个字符; 67 | - 改进:对搜索结果进行过滤,去除了部分返回值; 68 | - 改进:改为获取文档Markdown内容,不再获取Kramdown内容; 69 | 70 | ### v0.1.1 (2025年5月12日) 71 | 72 | - 修复:工具“追加到指定文档”总是失败的问题; 73 | - 修复:工具“追加到日记”缺失工具描述的问题; 74 | - 改进:工具“搜索”补充 `groupBy` `orderBy` `method`参数; 75 | 76 | ### v0.1.0 (2025年5月4日) 77 | 78 | - 从这里开始; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_zh_cn.yml: -------------------------------------------------------------------------------- 1 | name: 问题反馈 2 | description: 提交非预期行为、错误或缺陷报告 3 | assignees: [] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | 感谢提交问题反馈!请尽可能详细地填写以下内容,以帮助我们理解和解决问题。 9 | 10 | - type: textarea 11 | id: problem-description 12 | attributes: 13 | label: 问题现象 14 | description: 尽可能详细地描述问题的表现。 15 | placeholder: 在此描述问题... 16 | validations: 17 | required: true 18 | 19 | - type: markdown 20 | attributes: 21 | value: | 22 | 如果问题不能通过某些步骤稳定重现,请在错误发生时关注`Ctrl+Shift+I`开发者工具中的`Console`/`控制台`中的有关提示信息,将遇到问题时的错误信息截图上传。 23 | 24 | - type: textarea 25 | id: reproduce-steps 26 | attributes: 27 | label: 复现操作 28 | description: 描述重现问题所需要的步骤或设置项。如果不能稳定重现,请说明问题的发生频率,并上传错误提示信息。 29 | placeholder: | 30 | 1. 打开插件的xxx功能; 31 | 2. 打开xxx文档; 32 | 3. 问题出现; 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | id: screenshots-or-recordings 38 | attributes: 39 | label: 截图或录屏说明 40 | description: 请上传截图或录屏来演示问题。(可不填) 41 | placeholder: 在此提供截图或录屏... 42 | 43 | - type: textarea 44 | id: expected-behavior 45 | attributes: 46 | label: 预期行为 47 | description: 描述你认为插件应当表现出怎样的行为或结果(可不填) 48 | placeholder: 在此描述预期行为... 49 | 50 | - type: textarea 51 | attributes: 52 | label: 设备和系统信息 53 | description: | 54 | 示例: 55 | - **操作系统**: Windows11 24H2 56 | - **Siyuan**:v3.1.25 57 | - **插件版本**:v0.2.0 58 | value: | 59 | - 操作系统: 60 | - Siyuan: 61 | - 插件版本: 62 | render: markdown 63 | validations: 64 | required: true 65 | 66 | - type: checkboxes 67 | id: check_list 68 | attributes: 69 | label: 检查单 70 | description: 在提交前,请确认这些事项 71 | options: 72 | - label: 我已经查询了issue列表,我认为没有人反馈过类似问题 73 | required: true 74 | - label: 我已经将插件升级到最新版本 75 | required: true 76 | 77 | - type: textarea 78 | id: additional-info 79 | attributes: 80 | label: 其他补充信息 81 | description: 如有其他相关信息,请在此提供。例如插件设置、思源设置等。 82 | placeholder: 在此提供补充信息... 83 | -------------------------------------------------------------------------------- /src/indexer/indexConsumer.ts: -------------------------------------------------------------------------------- 1 | import { debugPush, logPush } from "@/logger"; 2 | import { exportMdContent } from "@/syapi"; 3 | import { getSubDocIds } from "@/syapi/custom"; 4 | import { isValidStr } from "@/utils/commonCheck"; 5 | import { useProvider, useQueue } from "@/utils/indexerHelper"; 6 | import { getPluginInstance } from "@/utils/pluginHelper"; 7 | import { IEventBusMap } from "siyuan"; 8 | 9 | export class IndexConsumer { 10 | _interval; 11 | 12 | start() { 13 | this._interval = setInterval(this.consume.bind(this), 5000); 14 | } 15 | stop() { 16 | clearInterval(this._interval); 17 | } 18 | async consume() { 19 | debugPush("queue consuming"); 20 | const queue = useQueue(); 21 | // 从队列中取出5个id 22 | const idItem = await queue.consume(5); 23 | // 扩充id,部分会提供hasChild选项,这个时候需要获取子文档 24 | const idList = []; 25 | for (let item of idItem) { 26 | idList.push(item["id"]); 27 | // OLD: 现在禁用了hasChild方式,由eventHandler获取 28 | // if (item["hasChild"]) { 29 | // const subDocIds = await getSubDocIds(item["id"]); 30 | // if (subDocIds != null && subDocIds.length > 0) { 31 | // idList.push(...subDocIds); 32 | // } 33 | // } 34 | // if (isValidStr(item["id"])) { 35 | // idList.push(item["id"]); 36 | // } 37 | } 38 | // 获取id对应的文档内容 39 | const contentPromiseList = idList.map(item=>exportMdContent({id: item, refMode: 4, embedMode: 1, yfm: false})); 40 | const contentList = await Promise.all(contentPromiseList); 41 | // 发送 42 | const provider = useProvider(); 43 | for (let i = 0; i < contentList.length; i++) { 44 | if (!isValidStr(idList[i]) || !isValidStr(contentList[i]["content"])) { 45 | debugPush("submit ERROR", idList[i], contentList[i]["content"]); 46 | continue; 47 | } 48 | provider.update(idList[i], contentList[i]["content"]).catch(err=>{ 49 | logPush("RAG提交索引时遇到问题,该提交已被重新暂存!"); 50 | }); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/types/sytypes.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 frostime. All rights reserved. 3 | */ 4 | 5 | /** 6 | * Frequently used data structures in SiYuan 7 | */ 8 | type DocumentId = string; 9 | type BlockId = string; 10 | type NotebookId = string; 11 | type PreviousID = BlockId; 12 | type ParentID = BlockId | DocumentId; 13 | 14 | type Notebook = { 15 | id: NotebookId; 16 | name: string; 17 | icon: string; 18 | sort: number; 19 | closed: boolean; 20 | } 21 | 22 | type NotebookConf = { 23 | name: string; 24 | closed: boolean; 25 | refCreateSavePath: string; 26 | createDocNameTemplate: string; 27 | dailyNoteSavePath: string; 28 | dailyNoteTemplatePath: string; 29 | } 30 | 31 | type BlockType = "d" | "s" | "h" | "t" | "i" | "p" | "f" | "audio" | "video" | "other"; 32 | 33 | type BlockSubType = "d1" | "d2" | "s1" | "s2" | "s3" | "t1" | "t2" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "table" | "task" | "toggle" | "latex" | "quote" | "html" | "code" | "footnote" | "cite" | "collection" | "bookmark" | "attachment" | "comment" | "mindmap" | "spreadsheet" | "calendar" | "image" | "audio" | "video" | "other"; 34 | 35 | type Block = { 36 | id: BlockId; 37 | parent_id?: BlockId; 38 | root_id: DocumentId; 39 | hash: string; 40 | box: string; 41 | path: string; 42 | hpath: string; 43 | name: string; 44 | alias: string; 45 | memo: string; 46 | tag: string; 47 | content: string; 48 | fcontent?: string; 49 | markdown: string; 50 | length: number; 51 | type: BlockType; 52 | subtype: BlockSubType; 53 | ial?: { [key: string]: string }; 54 | sort: number; 55 | created: string; 56 | updated: string; 57 | } 58 | 59 | /** 60 | * By OpaqueGlass. Copy from https://github.com/siyuan-note/siyuan/blob/master/app/src/types/index.d.ts 61 | */ 62 | interface IFile { 63 | icon: string; 64 | name1: string; 65 | alias: string; 66 | memo: string; 67 | bookmark: string; 68 | path: string; 69 | name: string; 70 | hMtime: string; 71 | hCtime: string; 72 | hSize: string; 73 | dueFlashcardCount?: string; 74 | newFlashcardCount?: string; 75 | flashcardCount?: string; 76 | id: string; 77 | count: number; 78 | subFileCount: number; 79 | } 80 | -------------------------------------------------------------------------------- /src/utils/fakeEncrypt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 历史遗留代码,具体功能大模型应该可以理解 3 | */ 4 | // (function(_0x3764bd,_0x33cac4){const _0x3bb92c=_0x5977,_0x4d41ae=_0x3764bd();while(!![]){try{const _0x2dab8f=parseInt(_0x3bb92c(0xdb))/0x1+-parseInt(_0x3bb92c(0xda))/0x2+parseInt(_0x3bb92c(0xd1))/0x3*(-parseInt(_0x3bb92c(0xd0))/0x4)+parseInt(_0x3bb92c(0xcb))/0x5+-parseInt(_0x3bb92c(0xca))/0x6+-parseInt(_0x3bb92c(0xd9))/0x7+-parseInt(_0x3bb92c(0xcc))/0x8*(-parseInt(_0x3bb92c(0xce))/0x9);if(_0x2dab8f===_0x33cac4)break;else _0x4d41ae['push'](_0x4d41ae['shift']());}catch(_0x407699){_0x4d41ae['push'](_0x4d41ae['shift']());}}}(_0xfed4,0x72d85));function _0x5977(_0x4a3f41,_0x54101b){const _0xfed493=_0xfed4();return _0x5977=function(_0x5977b4,_0x373342){_0x5977b4=_0x5977b4-0xca;let _0x179487=_0xfed493[_0x5977b4];return _0x179487;},_0x5977(_0x4a3f41,_0x54101b);}function _0xfed4(){const _0x47ce6f=['22705803vHCHzy','length','749780eEZEop','6TQxsgR','config','push','system','a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8','replace','charCodeAt','siyuan','2257157DJudrs','1528486SWPGvG','97369gdBCHF','4944372elQQpY','679075BDZomk','8FnykuF','fromCharCode'];_0xfed4=function(){return _0x47ce6f;};return _0xfed4();}function __𝑺(){const _0x69429d=_0x5977;return window?.[_0x69429d(0xd8)]?.[_0x69429d(0xd2)]?.[_0x69429d(0xd4)]?.['id']??_0x69429d(0xd5);}function __𝑻(_0x36b18f,_0x4c80dd){const _0x1eaf63=_0x5977;let _0x218fe0=_0x4c80dd[_0x1eaf63(0xd6)](/[^A-Za-z0-9]/g,''),_0xe77a35=[];for(let _0x246610=0x0;_0x246610<_0x36b18f[_0x1eaf63(0xcf)];++_0x246610){let _0x239d1f=_0x218fe0['charCodeAt'](_0x246610%_0x218fe0['length']);_0xe77a35[_0x1eaf63(0xd3)](0x3+_0x239d1f%0xa);}return _0xe77a35;}export function 𝑬𝑿𝑻𝑹𝑨𝑽𝑨𝑮𝑨𝑵𝒁𝑨(_0x12d5a4){const _0x1c1a42=_0x5977;let _0x5c9a1e=__𝑺(),_0x4d35cf=__𝑻(_0x12d5a4,_0x5c9a1e),_0x1e4164='';for(let _0x566c79=0x0;_0x566c79<_0x12d5a4[_0x1c1a42(0xcf)];++_0x566c79){let _0x3e1371=_0x12d5a4[_0x1c1a42(0xd7)](_0x566c79),_0x24829b=_0x4d35cf[_0x566c79];_0x1e4164+=String[_0x1c1a42(0xcd)]((_0x3e1371-0x20+_0x24829b)%0x5f+0x20);}return _0x1e4164;}export function 𝑰𝑵𝑽𝑬𝑹𝑺𝑬_𝑬𝑿𝑻𝑹𝑨𝑽𝑨𝑮𝑨𝑵𝒁𝑨(_0x5b03f9){const _0x580634=_0x5977;let _0x229554=__𝑺(),_0x19fb8c=__𝑻(_0x5b03f9,_0x229554),_0x2f690f='';for(let _0xc31ed3=0x0;_0xc31ed3<_0x5b03f9['length'];++_0xc31ed3){let _0x5ecef0=_0x5b03f9[_0x580634(0xd7)](_0xc31ed3),_0x1e78cd=_0x19fb8c[_0xc31ed3];_0x2f690f+=String[_0x580634(0xcd)]((_0x5ecef0-0x20-_0x1e78cd+0x5f)%0x5f+0x20);}return _0x2f690f;} -------------------------------------------------------------------------------- /src/types/mcp.ts: -------------------------------------------------------------------------------- 1 | import type { z } from "zod"; 2 | declare global { 3 | type McpTextContent = { 4 | [x: string]: unknown; 5 | type: "text"; 6 | text: string; 7 | }; 8 | 9 | type McpImageContent = { 10 | [x: string]: unknown; 11 | type: "image"; 12 | data: string; 13 | mimeType: string; 14 | }; 15 | 16 | type McpResourceContent = { 17 | [x: string]: unknown; 18 | type: "resource"; 19 | resource: { 20 | [x: string]: unknown; 21 | text: string; 22 | uri: string; 23 | mimeType?: string; 24 | } | { 25 | [x: string]: unknown; 26 | uri: string; 27 | blob: string; 28 | mimeType?: string; 29 | }; 30 | }; 31 | 32 | type McpContent = McpTextContent | McpImageContent | McpResourceContent; 33 | 34 | /** 35 | * Standard MCP response format 36 | * Must match the MCP SDK expected format 37 | */ 38 | interface McpResponse { 39 | [x: string]: unknown; 40 | content: McpContent[]; 41 | isError?: boolean; 42 | _meta?: Record; 43 | } 44 | interface McpTool { 45 | /** 46 | * The name of the tool 47 | */ 48 | name: string; 49 | 50 | /** 51 | * The description of the tool 52 | */ 53 | description: string; 54 | 55 | /** 56 | * The Zod schema for validating tool arguments 57 | * This should be a record of Zod validators 58 | * For tools with no parameters, use {} (empty object) 59 | */ 60 | schema: Record> | undefined; 61 | 62 | /** 63 | * The handler function for the tool 64 | */ 65 | handler: (args: T, extra: any) => Promise; 66 | 67 | title?: string; // Human-readable title for the tool 68 | 69 | /** 70 | * The tool annotations 71 | */ 72 | annotations?: { // Optional hints about tool behavior 73 | readOnlyHint?: boolean; // If true, the tool does not modify its environment 74 | destructiveHint?: boolean; // If true, the tool may perform destructive updates 75 | idempotentHint?: boolean; // If true, repeated calls with same args have no additional effect 76 | openWorldHint?: boolean; // If true, tool interacts with external entities 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/types/siyuan.ts: -------------------------------------------------------------------------------- 1 | import { IObject, ISiyuan } from "siyuan"; 2 | declare global { 3 | interface Window { 4 | echarts: { 5 | init(element: HTMLElement, theme?: string, options?: { 6 | width: number 7 | }): { 8 | setOption(option: any): void; 9 | getZr(): any; 10 | on(name: string, event: (e: any) => void): any; 11 | containPixel(name: string, position: number[]): any; 12 | resize(): void; 13 | }; 14 | dispose(element: Element): void; 15 | getInstanceById(id: string): { 16 | resize: () => void 17 | }; 18 | } 19 | ABCJS: { 20 | renderAbc(element: Element, text: string, options: { 21 | responsive: string 22 | }): void; 23 | } 24 | hljs: { 25 | listLanguages(): string[]; 26 | highlight(text: string, options: { 27 | language?: string, 28 | ignoreIllegals: boolean 29 | }): { 30 | value: string 31 | }; 32 | getLanguage(text: string): { 33 | name: string 34 | }; 35 | }; 36 | katex: { 37 | renderToString(math: string, option?: { 38 | displayMode?: boolean; 39 | output?: string; 40 | macros?: IObject; 41 | trust?: boolean; 42 | strict?: (errorCode: string) => "ignore" | "warn"; 43 | }): string; 44 | } 45 | mermaid: { 46 | initialize(options: any): void, 47 | init(options: any, element: Element): void 48 | }; 49 | plantumlEncoder: { 50 | encode(options: string): string, 51 | }; 52 | pdfjsLib: any 53 | 54 | dataLayer: any[] 55 | 56 | siyuan: ISiyuan 57 | webkit: any 58 | html2canvas: (element: Element, opitons: { 59 | useCORS: boolean, 60 | scale?: number 61 | }) => Promise; 62 | JSAndroid: { 63 | returnDesktop(): void 64 | openExternal(url: string): void 65 | changeStatusBarColor(color: string, mode: number): void 66 | writeClipboard(text: string): void 67 | writeImageClipboard(uri: string): void 68 | readClipboard(): string 69 | getBlockURL(): string 70 | } 71 | 72 | Protyle: any 73 | 74 | goBack(): void 75 | 76 | reconnectWebSocket(): void 77 | 78 | showKeyboardToolbar(height: number): void 79 | 80 | hideKeyboardToolbar(): void 81 | 82 | openFileByURL(URL: string): boolean 83 | 84 | destroyTheme(): Promise 85 | 86 | } 87 | } -------------------------------------------------------------------------------- /src/utils/resultFilter.ts: -------------------------------------------------------------------------------- 1 | import { logPush } from "@/logger"; 2 | import { isValidStr } from "./commonCheck"; 3 | 4 | /** 5 | * 以文档分组下,过滤检索的结果 6 | * @param inputDataList 检索结果中的blocks字段 7 | */ 8 | export function filterGroupSearchBlocksResult(inputDataList) { 9 | if (inputDataList == null) { 10 | return []; 11 | } 12 | let result = inputDataList.map((item)=>{ 13 | let children = item["children"] ? item.children.map((childItem)=>getSearchResultString(childItem)) : []; 14 | return { 15 | "notebookId": item["box"], 16 | "path": item["path"], 17 | "docId": item["rootID"], 18 | "docName": item["content"] , 19 | "hPath": item["hPath"], 20 | "tag": item["tag"], 21 | "memo": item["memo"], 22 | "children": children 23 | } 24 | }); 25 | return result; 26 | } 27 | 28 | /** 29 | * 从块结果获取检索结果字符串(仅) 30 | * @param inputData SearchResult 31 | * @returns 用于反映检索结果的内容 32 | */ 33 | export function getSearchResultString(inputData) { 34 | if (!isValidStr(inputData["markdown"])) { 35 | return inputData["fcontent"] ?? ""; 36 | } 37 | return inputData["markdown"]; 38 | } 39 | 40 | export function filterSearchBlocksResult(inputDataList) { 41 | if (inputDataList == null) { 42 | return []; 43 | } 44 | return inputDataList.map((item)=>{ 45 | return { 46 | "notebookId": item["box"], 47 | "path": item["path"], 48 | "docId": item["rootID"], 49 | "blockId": item["id"], 50 | "content": item["markdown"] , 51 | "docHumanPath": item["hPath"], 52 | "tag": item["tag"], 53 | "memo": item["memo"], 54 | "alias": item["alias"] 55 | } 56 | }); 57 | } 58 | 59 | export function formatSearchResult(responseObj, requestObj: FullTextSearchQuery) { 60 | let pageDesp = `This is page ${requestObj["page"] ?? "1"} of a paginated API response. 61 | ${responseObj["matchedRootCount"]} documents and ${responseObj["matchedBlockCount"]} content blocks matched the search, across ${responseObj["pageCount"]} total pages.`; 62 | let data = null; 63 | let anyResult = responseObj["blocks"] == null || responseObj["blocks"].length == 0 ? null : responseObj["blocks"][0]; 64 | if (requestObj.groupBy == 1 || anyResult?.children) { 65 | data = filterGroupSearchBlocksResult(responseObj["blocks"]); 66 | } else { 67 | data = filterSearchBlocksResult(responseObj["blocks"]); 68 | } 69 | return `${pageDesp} 70 | Search Result: 71 | ${JSON.stringify(data)}`; 72 | } 73 | -------------------------------------------------------------------------------- /TOOL_INFO.md: -------------------------------------------------------------------------------- 1 | ## 工具列表 2 | 3 | | 分类 | 功能项 | 排除文档 | 状态/说明 | 4 | | ------------- | ------------------------------ | ---------------------------------------------------------------- | ------------------------------ | 5 | | 检索 | 使用关键词搜索 | | 暂时移除,如有需要请反馈 | 6 | | 检索 | 使用 SQL 搜索 | 不处理排除笔记本,仅在返回值有id字段且返回条目数<300时进行检查 | — | 7 | | 检索 | 笔记索引库问答(RAG 后端) | | 即将移除,后续将使用其他方案 | 8 | | 获取 | 通过 id 获取文档 markdown | v | — | 9 | | 获取 | 通过 id 获取块 kramdown | v | — | 10 | | 获取 | 列出笔记本 | | — | 11 | | 获取 | 通过 id 获取反向链接 | v | — | 12 | | 获取 | 获取文档的子文档列表 | v | — | 13 | | 获取 | 读取属性 | v | — | 14 | | 获取 | 读取指定日期日记 | | 暂时移除,如有需要请反馈 | 15 | | 写入 / 文档 | 向日记追加内容 | v | — | 16 | | 写入 / 文档 | 通过 id 向指定文档追加内容 | v | — | 17 | | 写入 / 文档 | 通过 id 在指定位置创建新文档 | v | — | 18 | | 写入 / 文档 | 插入子块(前置/后置) | v | — | 19 | | 写入 / 文档 | 插入块(指定位置) | v | — | 20 | | 写入 / 文档 | 更新块 | v | — | 21 | | 写入 / 闪卡 | 通过 Markdown 创建闪卡 | v | — | 22 | | 写入 / 闪卡 | 通过块 id 创建闪卡 | v | — | 23 | | 写入 / 闪卡 | 通过块 id 删除闪卡 | 不支持排除文档 | — | 24 | | 写入 / 属性 | 更改属性(增删改) | v | — | -------------------------------------------------------------------------------- /src/indexer/myProvider.ts: -------------------------------------------------------------------------------- 1 | import { debugPush, logPush } from "@/logger"; 2 | import {IndexProvider} from "@/indexer/baseIndexProvider"; 3 | import { isValidStr } from "@/utils/commonCheck"; 4 | 5 | type QueryResult = Record | any[] | null; 6 | export class MyIndexProvider extends IndexProvider { 7 | private base_url: string; 8 | private api_key: string; 9 | 10 | constructor(baseUrl=undefined, apiKey=undefined) { 11 | super(); 12 | this.base_url = baseUrl ?? "http://127.0.0.1:26808"; 13 | if (isValidStr(this.base_url)) { 14 | this.base_url += this.base_url.endsWith("/") ? "api/v1" : "/api/v1"; 15 | } 16 | this.api_key = apiKey ?? ""; 17 | } 18 | 19 | private get headers() { 20 | return { 21 | "Content-Type": "application/json", 22 | "x-api-key": this.api_key, 23 | }; 24 | } 25 | 26 | async update(id: string, content: string): Promise { 27 | const url = `${this.base_url}/index`; 28 | const body = JSON.stringify({ id, content }); 29 | const resp = await fetch(url, { 30 | method: "POST", 31 | headers: this.headers, 32 | body, 33 | }); 34 | if (!resp.ok) { 35 | const msg = await resp.text(); 36 | throw new Error(`Index update failed: ${resp.status} - ${msg}`); 37 | } 38 | } 39 | 40 | async delete(id: string): Promise { 41 | const url = `${this.base_url}/index/${encodeURIComponent(id)}`; 42 | const resp = await fetch(url, { 43 | method: "DELETE", 44 | headers: this.headers, 45 | }); 46 | if (!resp.ok) { 47 | const msg = await resp.text(); 48 | throw new Error(`Index delete failed: ${resp.status} - ${msg}`); 49 | } 50 | } 51 | 52 | async query(query: string, top_k: number = 5): Promise { 53 | const url = `${this.base_url}/query`; 54 | const body = JSON.stringify({ query, top_k }); 55 | const resp = await fetch(url, { 56 | method: "POST", 57 | headers: this.headers, 58 | body, 59 | }); 60 | if (!resp.ok) { 61 | const msg = await resp.text(); 62 | throw new Error(`Index query failed: ${resp.status} - ${msg}`); 63 | } 64 | const result = await resp.json(); 65 | logPush("result", result); 66 | return result.result; 67 | } 68 | 69 | async health() { 70 | const url = `${this.base_url}/health`; 71 | try { 72 | const resp = await fetch(url, { 73 | method: "GET", 74 | headers: this.headers, 75 | }); 76 | if (!resp.ok) { 77 | return null; 78 | } 79 | const result = await resp.json(); 80 | return result; 81 | } catch (e) { 82 | debugPush("health check error", e); 83 | return null; 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Submit a report for unexpected behavior, errors, or defects 3 | assignees: [] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for submitting a bug report! Please provide as much detail as possible to help us understand and resolve the issue. 9 | 10 | - type: textarea 11 | id: problem-description 12 | attributes: 13 | label: Issue Description 14 | description: Describe the issue in as much detail as possible. 15 | placeholder: Describe the issue here... 16 | validations: 17 | required: true 18 | 19 | - type: markdown 20 | attributes: 21 | value: | 22 | If the issue cannot be consistently reproduced through specific steps, please check the `Console` in `Ctrl+Shift+I` Developer Tools for relevant messages and upload a screenshot of the error when the error occurs. 23 | 24 | - type: textarea 25 | id: reproduce-steps 26 | attributes: 27 | label: Steps to Reproduce 28 | description: Describe the steps or settings required to reproduce the issue. If it cannot be consistently reproduced, specify the frequency of occurrence and upload error messages if available. 29 | placeholder: | 30 | 1. Open the xxx feature in the plugin; 31 | 2. Open the xxx document; 32 | 3. The issue occurs; 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | id: screenshots-or-recordings 38 | attributes: 39 | label: Screenshots or Recordings 40 | description: Please upload screenshots or recordings to demonstrate the issue. (Optional) 41 | placeholder: Provide screenshots or recordings here... 42 | 43 | - type: textarea 44 | id: expected-behavior 45 | attributes: 46 | label: Expected Behavior 47 | description: Describe what you expect the plugin to do or the correct outcome. (Optional) 48 | placeholder: Describe the expected behavior here... 49 | 50 | - type: textarea 51 | attributes: 52 | label: Device and System Information 53 | description: | 54 | Example: 55 | - **Operating System**: Windows 11 24H2 56 | - **Siyuan**: v3.1.25 57 | - **Plugin Version**: v0.2.0 58 | value: | 59 | - Operating System: 60 | - Siyuan: 61 | - Plugin Version: 62 | render: markdown 63 | validations: 64 | required: true 65 | 66 | - type: checkboxes 67 | id: check_list 68 | attributes: 69 | label: Checklist 70 | description: Before submitting, please confirm the following 71 | options: 72 | - label: I have searched the issue list and found no similar reports 73 | required: true 74 | - label: I have updated the plugin to the latest version 75 | required: true 76 | 77 | - type: textarea 78 | id: additional-info 79 | attributes: 80 | label: Additional Information 81 | description: Provide any other relevant details here, such as plugin settings or Siyuan settings. 82 | placeholder: Provide additional information here... -------------------------------------------------------------------------------- /src/utils/queue.ts: -------------------------------------------------------------------------------- 1 | import { generateUUID } from "./common"; 2 | import Mutex from "./mutex"; 3 | import { debugPush } from "@/logger"; 4 | 5 | class Task { 6 | constructor( 7 | public id: string, 8 | public execute: () => Promise | T, 9 | public enqueueTime: number, // 任务入队时间,由队列维护 10 | public resolve: (value: T | PromiseLike) => void, 11 | public reject: (reason?: any) => void 12 | ) {} 13 | } 14 | 15 | export class MyDelayQueue { 16 | private tasks: Task[] = []; 17 | private delayTime: number; // 所有任务的统一延迟时间(秒) 18 | private timer: NodeJS.Timeout | null = null; 19 | 20 | private mutex: Mutex = new Mutex(); 21 | 22 | constructor(delayTime: number) { 23 | this.delayTime = delayTime * 1000; // 延迟时间转为毫秒 24 | } 25 | 26 | // 入队任务,返回一个Promise,该Promise将在任务执行后解析 27 | enqueue(task: () => Promise | T): Promise { 28 | return new Promise((resolve, reject) => { 29 | const enqueueTime = Date.now(); 30 | const taskId = generateUUID(); 31 | const taskWithTime = new Task(taskId, task, enqueueTime, resolve, reject); 32 | this.tasks.push(taskWithTime); 33 | 34 | if (!this.timer) { 35 | this.setTimer(); 36 | } 37 | }); 38 | } 39 | 40 | // 出队任务 41 | dequeue() { 42 | return this.tasks.shift(); 43 | } 44 | 45 | signalOne() { 46 | if (this.timer) { 47 | clearTimeout(this.timer); 48 | this.timer = null; 49 | } 50 | this.processTask(); 51 | } 52 | 53 | // 设置定时器,每次检查队列中的任务 54 | private setTimer() { 55 | if (this.tasks.length === 0) { 56 | return; 57 | } 58 | const nextExecutionTime = this.tasks[0].enqueueTime + this.delayTime; 59 | const delay = Math.max(0, nextExecutionTime - Date.now()); 60 | 61 | this.timer = setTimeout(() => { 62 | this.processTask(); 63 | }, delay); 64 | } 65 | 66 | // 处理任务 67 | private async processTask() { 68 | if (this.tasks.length === 0) { 69 | return; 70 | } 71 | 72 | await this.mutex.lock(); 73 | try { 74 | if (this.tasks.length > 0) { 75 | const now = Date.now(); 76 | const task = this.tasks[0]; 77 | 78 | if (now >= task.enqueueTime + this.delayTime) { 79 | debugPush(`消费任务检查: ${task.id}`); 80 | this.dequeue(); // 先出队,避免重复执行 81 | try { 82 | const result = await task.execute(); 83 | task.resolve(result); 84 | } catch (error) { 85 | task.reject(error); 86 | } 87 | 88 | } 89 | } 90 | } finally { 91 | this.mutex.unlock(); 92 | if (this.tasks.length > 0) { 93 | this.setTimer(); 94 | } else { 95 | this.timer = null; // 如果队列为空,清除定时器 96 | } 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /src/types/api.d.ts: -------------------------------------------------------------------------------- 1 | interface IFile { 2 | icon: string; 3 | name1: string; 4 | alias: string; 5 | memo: string; 6 | bookmark: string; 7 | path: string; 8 | name: string; 9 | hMtime: string; 10 | hCtime: string; 11 | hSize: string; 12 | dueFlashcardCount?: string; 13 | newFlashcardCount?: string; 14 | flashcardCount?: string; 15 | id: string; 16 | count: number; 17 | subFileCount: number; 18 | } 19 | 20 | interface SqlResult { 21 | alias: string; 22 | box: string; 23 | content: string; 24 | created: string; 25 | fcontent: string; 26 | hash: string; 27 | hpath: string; 28 | ial: string; 29 | id: string; 30 | length: number; 31 | markdown: string; 32 | memo: string; 33 | name: string; 34 | parent_id: string; 35 | path: string; 36 | root_id: string; 37 | sort: number; 38 | subtype: SqlBlockSubType; 39 | tag: string; 40 | type: SqlBlockType; 41 | updated: string; 42 | } 43 | 44 | type SqlBlockType = "d" | "p" | "h" | "l" | "i" | "b" | "html" | "widget" | "tb" | "c" | "s" | "t" | "iframe" | "av" | "m" | "query_embed" | "video" | "audio"; 45 | 46 | type SqlBlockSubType = "o" | "u" | "t" | "" |"h1" | "h2" | "h3" | "h4" | "h5" | "h6" 47 | 48 | 49 | interface BlockTypeFilter { 50 | audioBlock: boolean; 51 | blockquote: boolean; 52 | codeBlock: boolean; 53 | databaseBlock: boolean; 54 | document: boolean; 55 | embedBlock: boolean; 56 | heading: boolean; 57 | htmlBlock: boolean; 58 | iframeBlock: boolean; 59 | list: boolean; 60 | listItem: boolean; 61 | mathBlock: boolean; 62 | paragraph: boolean; 63 | superBlock: boolean; 64 | table: boolean; 65 | videoBlock: boolean; 66 | widgetBlock: boolean; 67 | } 68 | 69 | interface FullTextSearchQuery { 70 | query: string; 71 | method?: number; 72 | types?: BlockTypeFilter; 73 | paths?: string[]; 74 | groupBy?: number; 75 | orderBy?: number; 76 | page?: number; 77 | reqId?: number; 78 | pageSize?: number; 79 | } 80 | 81 | 82 | interface ExportMdContentBody { 83 | id: string, 84 | refMode: number, 85 | // 内容块引用导出模式 86 | // 2:锚文本块链 87 | // 3:仅锚文本 88 | // 4:块引转脚注+锚点哈希 89 | // (5:锚点哈希 https://github.com/siyuan-note/siyuan/issues/10265 已经废弃 https://github.com/siyuan-note/siyuan/issues/13331) 90 | // (0:使用原始文本,1:使用 Blockquote,都已经废弃 https://github.com/siyuan-note/siyuan/issues/3155) 91 | embedMode: number, 92 | // 内容块引用导出模式,0:使用原始文本,1:使用 Blockquote 93 | yfm: boolean, 94 | // Markdown 导出时是否添加 YAML Front Matter 95 | } 96 | 97 | export interface NotebookConf { 98 | box: string; 99 | conf: { 100 | name: string; 101 | sort: number; 102 | icon: string; 103 | closed: boolean; 104 | refCreateSaveBox: string; 105 | refCreateSavePath: string; 106 | docCreateSaveBox: string; 107 | docCreateSavePath: string; 108 | dailyNoteSavePath: string; 109 | dailyNoteTemplatePath: string; 110 | sortMode: number; 111 | }; 112 | name: string; 113 | } -------------------------------------------------------------------------------- /src/tools/sql.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createErrorResponse, createJsonResponse, createSuccessResponse } from "../utils/mcpResponse"; 3 | import { queryAPI } from "@/syapi"; 4 | import databaseSchema from "@/../static/database_schema.md"; 5 | import { isSelectQuery } from "@/utils/commonCheck"; 6 | import { commonPushCheck, debugPush, logPush } from "@/logger"; 7 | import { McpToolsProvider } from "./baseToolProvider"; 8 | import { lang } from "@/utils/lang"; 9 | import { getBlockDBItem } from "@/syapi/custom"; 10 | import { filterBlock } from "@/utils/filterCheck"; 11 | 12 | export class SqlToolProvider extends McpToolsProvider { 13 | async getTools(): Promise[]> { 14 | return [ 15 | { 16 | name: "siyuan_database_schema", 17 | description: "Provides the SiYuan database schema, including table names, field names, and their relationships, to help construct valid SQL queries for retrieving notes or note content. Returns the schema in markdown format.", 18 | schema: {}, 19 | handler: schemaHandler, 20 | title: lang("tool_title_database_schema"), 21 | annotations: { 22 | readOnlyHint: true, 23 | }, 24 | }, 25 | { 26 | name: "siyuan_query_sql", 27 | description: `Execute SQL queries to retrieve data (including notes, documents, and their content) from the SiYuan database. This tool is also used when you need to search notes content. 28 | Always use the 'siyuan_database_schema' tool to understand the database schema, including table names, field names, and relationships, before writing your query and use this tool.`, 29 | schema: { 30 | stmt: z.string().describe("A valid SQL SELECT statement to execute"), 31 | }, 32 | handler: sqlHandler, 33 | title: lang("tool_title_query_sql"), 34 | annotations: { 35 | readOnlyHint: true, 36 | }, 37 | }, 38 | 39 | ]; 40 | 41 | } 42 | } 43 | 44 | async function sqlHandler(params, extra) { 45 | const { stmt } = params; 46 | debugPush("SQL API 被调用", stmt); 47 | if (!isSelectQuery(stmt)) { 48 | return createErrorResponse("Not a SELECT statement"); 49 | } 50 | let sqlResult; 51 | try { 52 | sqlResult = await queryAPI(stmt); 53 | } catch (error) { 54 | return createErrorResponse(error instanceof Error ? error.message : String(error)); 55 | } 56 | debugPush("SQLAPI返回ing", sqlResult); 57 | // 如果sql返回字段包括id, 需要筛选id,将被过滤掉的id去除 58 | if (sqlResult.length > 0 && sqlResult.length < 300 && 'id' in sqlResult[0]) { 59 | const filteredResult = []; 60 | for (const row of sqlResult) { 61 | const id = row['id']; 62 | const dbItem = await getBlockDBItem(id); 63 | if (dbItem && await filterBlock(id, dbItem) === false) { 64 | filteredResult.push(dbItem); 65 | } 66 | } 67 | sqlResult = filteredResult; 68 | } 69 | return createJsonResponse(sqlResult); 70 | } 71 | 72 | async function schemaHandler(params, extra) { 73 | debugPush("schema API被调用"); 74 | return createSuccessResponse(databaseSchema); 75 | } -------------------------------------------------------------------------------- /src/utils/commonCheck.ts: -------------------------------------------------------------------------------- 1 | import { CONSTANTS } from "@/constants"; 2 | 3 | /** 4 | * 判定字符串是否有效 5 | * @param s 需要检查的字符串(或其他类型的内容) 6 | * @returns true / false 是否为有效的字符串 7 | */ 8 | export function isValidStr(s: any): boolean { 9 | if (s == undefined || s == null || s === '') { 10 | return false; 11 | } 12 | return true; 13 | } 14 | 15 | export function isValidAuthCode(str) { 16 | return /^[A-Za-z0-9+\-\/._~]{6,}$/.test(str); 17 | } 18 | 19 | export function isAuthCodeSetted(str) { 20 | if (str !== CONSTANTS.CODE_UNSET) { 21 | return true; 22 | } 23 | return false; 24 | } 25 | 26 | /** 27 | * 判断字符串是否为空白 28 | * @param s 字符串 29 | * @returns true 字符串为空或无效或只包含空白字符 30 | */ 31 | export function isBlankStr(s: any): boolean { 32 | if (!isValidStr(s)) return true; 33 | const clearBlankStr = s.replace(/\s+/g, ''); 34 | if (clearBlankStr === '') { 35 | return true; 36 | } 37 | return false; 38 | } 39 | 40 | let cacheIsMacOs = undefined; 41 | export function isMacOs() { 42 | let platform = window.top.siyuan.config.system.os ?? navigator.platform ?? "ERROR"; 43 | platform = platform.toUpperCase(); 44 | let isMacOSFlag = cacheIsMacOs; 45 | if (cacheIsMacOs == undefined) { 46 | for (let platformName of ["DARWIN", "MAC", "IPAD", "IPHONE", "IOS"]) { 47 | if (platform.includes(platformName)) { 48 | isMacOSFlag = true; 49 | break; 50 | } 51 | } 52 | cacheIsMacOs = isMacOSFlag; 53 | } 54 | if (isMacOSFlag == undefined) { 55 | isMacOSFlag = false; 56 | } 57 | return isMacOSFlag; 58 | } 59 | 60 | export function isEventCtrlKey(event) { 61 | if (isMacOs()) { 62 | return event.metaKey; 63 | } 64 | return event.ctrlKey; 65 | } 66 | 67 | export function isSelectQuery(sql: string): boolean { 68 | return sql.trim().toUpperCase().startsWith("SELECT"); 69 | } 70 | 71 | export function isValidNotebookId(id: string) { 72 | const notebooks = window.siyuan.notebooks; 73 | const result = notebooks.find(item=>item.id === id); 74 | return result != null; 75 | } 76 | 77 | export function isNonContainerBlockType(type: string) { 78 | const nonContainerTypes = ["audio", "av", "c", "html", "iframe", "m", "p", "t", "tb", "video", "widget", "h", "query_embed"]; 79 | return nonContainerTypes.includes(type); 80 | } 81 | 82 | export function isNonParentBlockType(type: string) { 83 | const nonContainerTypes = ["audio", "av", "c", "html", "iframe", "m", "p", "t", "tb", "video", "widget", "query_embed"]; 84 | return nonContainerTypes.includes(type); 85 | } 86 | 87 | /** 88 | * 解析版本号字符串,移除除数字和点之外的所有字符,并将其分割成数字数组。 89 | * 例如 "v3.1.2-beta" -> [3, 1, 2] 90 | * @param version - 版本号字符串 91 | * @returns - 由版本号各部分组成的数字数组 92 | */ 93 | const parseVersion = (version: string): number[] => { 94 | // 如果 version 为空或非字符串,返回空数组以避免错误 95 | if (!version || typeof version !== 'string') { 96 | return []; 97 | } 98 | return version.replace(/[^0-9.]/g, '').split('.').map(Number); 99 | }; 100 | 101 | /** 102 | * 比较当前内核版本是否小于输入的版本号。 103 | * @param version - 要比较的版本号字符串,例如 "3.1.23" 或 "3.2.1.1" 104 | * @returns boolean - 如果当前版本小于输入版本,则返回 true;否则(大于或等于)返回 false。 105 | */ 106 | export function isCurrentVersionLessThan(version: string): boolean { 107 | const parsedInputVersion = parseVersion(version); 108 | const parsedCurrentVersion = parseVersion(window.siyuan.config.system.kernelVersion); 109 | 110 | const len = Math.max(parsedCurrentVersion.length, parsedInputVersion.length); 111 | 112 | for (let i = 0; i < len; i++) { 113 | const currentPart = parsedCurrentVersion[i] || 0; 114 | const inputPart = parsedInputVersion[i] || 0; 115 | 116 | if (currentPart < inputPart) { 117 | return true; 118 | } 119 | if (currentPart > inputPart) { 120 | return false; 121 | } 122 | } 123 | return false; 124 | } -------------------------------------------------------------------------------- /src/tools/docWrite.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createErrorResponse, createSuccessResponse } from "../utils/mcpResponse"; 3 | import { appendBlockAPI, createDocWithPath } from "@/syapi"; 4 | import { checkIdValid, getDocDBitem, isADocId } from "@/syapi/custom"; 5 | import { McpToolsProvider } from "./baseToolProvider"; 6 | import { debugPush } from "@/logger"; 7 | import { createNewDocWithParentId } from "./sharedFunction"; 8 | 9 | import { lang } from "@/utils/lang"; 10 | import { TASK_STATUS, taskManager } from "@/utils/historyTaskHelper"; 11 | import { filterBlock } from "@/utils/filterCheck"; 12 | 13 | export class DocWriteToolProvider extends McpToolsProvider { 14 | async getTools(): Promise[]> { 15 | return [{ 16 | name: "siyuan_append_markdown_to_doc", 17 | description: 'Append Markdown content to the end of a document in SiYuan by its ID.', 18 | schema: { 19 | id: z.string().describe("The unique identifier of the document to which the Markdown content will be appended."), 20 | markdownContent: z.string().describe("The Markdown-formatted text to append to the end of the specified document."), 21 | }, 22 | handler: appendBlockHandler, 23 | title: lang("tool_title_append_markdown_to_doc"), 24 | annotations: { 25 | readOnlyHint: false, 26 | destructiveHint: false, 27 | idempotentHint: false, 28 | } 29 | }, { 30 | name: "siyuan_create_new_note_with_markdown_content", 31 | description: "Create a new note under a parent document in SiYuan with a specified title and Markdown content.", 32 | schema: { 33 | parentId: z.string().describe("The unique identifier (ID) of the parent document or notebook where the new note will be created."), 34 | title: z.string().describe("The title of the new note to be created."), 35 | markdownContent: z.string().describe("The Markdown content of the new note."), 36 | }, 37 | handler: createNewNoteUnder, 38 | title: lang("tool_title_create_new_note_with_markdown_content"), 39 | annotations: { 40 | readOnlyHint: false, 41 | destructiveHint: false, 42 | idempotentHint: false, 43 | } 44 | }]; 45 | } 46 | } 47 | 48 | async function appendBlockHandler(params, extra) { 49 | const { id, markdownContent } = params; 50 | debugPush("追加内容块API被调用"); 51 | checkIdValid(id); 52 | if (!await isADocId(id)) { 53 | return createErrorResponse("Failed to append to document: The provided ID is not the document's ID."); 54 | } 55 | if (await filterBlock(id, null)) { 56 | return createErrorResponse("The specified document or block is excluded by the user settings. So cannot write or read. "); 57 | } 58 | const result = await appendBlockAPI(markdownContent, id); 59 | if (result == null) { 60 | return createErrorResponse("Failed to append to the document"); 61 | } 62 | taskManager.insert(result.id, markdownContent, "appendToDocEnd", { docId: id}, TASK_STATUS.APPROVED); 63 | return createSuccessResponse("Successfully appended, the block ID for the new content is " + result.id); 64 | } 65 | 66 | async function createNewNoteUnder(params, extra) { 67 | const { parentId, title, markdownContent } = params; 68 | if (await filterBlock(parentId, null)) { 69 | return createErrorResponse("The specified document or block is excluded by the user settings, so cannot create a new note under it."); 70 | } 71 | debugPush("添加新笔记被调用"); 72 | const {result, newDocId} = await createNewDocWithParentId(parentId, title, markdownContent); 73 | if (result) { 74 | taskManager.insert(newDocId, markdownContent, "createNewNoteUnder", {}, TASK_STATUS.APPROVED); 75 | } 76 | return result ? createSuccessResponse(`成功创建文档,文档id为:${newDocId}`) : createErrorResponse("An Error Occured"); 77 | } -------------------------------------------------------------------------------- /README_zh_CN.md: -------------------------------------------------------------------------------- 1 | # 一个MCP服务端插件 2 | 3 | > 为[思源笔记](https://github.com/siyuan-note/siyuan)提供MCP服务的插件。 4 | 5 | > 当前版本: v0.6.0 6 | > 7 | > 新增:设置排除笔记本ID或文档ID,在部分工具避免返回敏感内容;(请注意此功能并非严格排除:由于实现方式的问题,对部分工具或部分情况并不过滤) 8 | > 9 | > 其他详见[更新日志](./CHANGELOG.md)。 10 | 11 | ## ✨快速开始 12 | 13 | - 从集市下载 或 1、解压Release中的`package.zip`,2、将文件夹移动到`工作空间/data/plugins/`,3、并将文件夹重命名为`syplugin-anMCPServer`; 14 | - 开启插件; 15 | - 打开插件设置,启动服务; 16 | - 插件默认监听`16806`端口(Host: `127.0.0.1`),请使用`http://127.0.0.1:16806/mcp`作为服务端访问地址; 17 | 18 | > ⭐ 如果这对你有帮助,请考虑点亮Star! 19 | 20 | ## 🔧支持的工具 21 | 22 | - 【检索】 23 | - ~~使用关键词搜索;~~ 24 | > 目前来看,在需要SQL动态检索时,大模型仍然使用此工具。暂时移除,如有需要请反馈 25 | - 使用SQL搜索; 26 | - ~~笔记索引库问答(使用RAG后端服务,[功能测试中](./RAG_BETA.md));~~ 27 | > 此功能即将被移除,后续将使用其他方案实现。 28 | - 【获取】 29 | - 通过id获取文档markdown; 30 | - 通过id获取块kramdown; 31 | - 列出笔记本; 32 | - 通过id获取反向链接; 33 | - 获取文档的子文档列表; 34 | - 读取属性; 35 | - ~~读取指定日期日记;~~ 36 | > 暂时移除,如有需要请反馈 37 | - 【写入】 38 | - 文档类 39 | - 向日记追加内容; 40 | - 通过id向指定文档追加内容; 41 | - 通过id在指定位置创建新文档; 42 | - 插入子块;(前置插入/后置插入) 43 | - 插入块;(指定位置) 44 | - 更新块; 45 | - 闪卡类 46 | - 通过Markdown内容创建闪卡; 47 | - 通过块id创建闪卡; 48 | - 通过块id删除闪卡; 49 | - 属性 50 | - 更改属性;(增删改) 51 | 52 | 53 | ## ❓可能常见的问题 54 | 55 | - Q: 如何在MCP客户端中使用? 56 | 请参考后文; 57 | - Q: 常见的MCP客户端有哪些? 58 | - 请参考:https://github.com/punkpeye/awesome-mcp-clients 或 https://modelcontextprotocol.io/clients ; 59 | - Q:插件支持鉴权吗? 60 | - v0.2.0版本已支持鉴权,在插件设置处设置鉴权token后,在MCP客户端,需要设置`authorization`请求头,其值为 `Bearer 你的Token`; 61 | - Q: 可以在docker使用吗? 62 | - 不可以,插件依赖nodejs环境,不支持在移动端、docker运行; 63 | 64 | > 若要支持docker中部署的思源,建议转为使用其他MCP项目,部分项目可能在[这里](https://github.com/siyuan-note/siyuan/issues/13795)列出; 65 | > 66 | > 或者,修改代码,将本插件和思源前端解耦; 67 | - Q: 如何查看已经设置的授权码? 68 | - 授权码哈希后保存,只能修改,不能查看生效中的授权码; 69 | - Q:什么是“笔记索引库问答(使用RAG后端服务)”工具? 70 | - “笔记索引库问答”是一种基于 RAG(Retrieval-Augmented Generation,检索增强生成)技术的问答工具。该工具允许语言模型在回答问题时,引用已被索引的笔记内容,确保生成的回答更加准确; 71 | - 参考[RAG_BETA文档](./RAG_BETA.md)正确部署并在插件设置 “插件RAG后端:请求baseURL” 之后,可以在文档树右键-插件选择索引文档,文档将发送到 RAG后端 进行索引(目前的实现使用对 [LightRAG](https://github.com/HKUDS/LightRAG) 简单包装的python后端); 72 | - 语言模型在用户提问时,可以调用该工具,获取基于 LightRAG 知识图谱给出的回答; 73 | - 注意:回答仅基于已被索引的文档,不会使用未被索引的文档内容。 74 | 75 | ## ✅如何在MCP客户端中配置? 76 | 77 | > MCP客户端不断迭代更新,这里的配置或使用说明未必能够直接套用,仅供参考; 78 | > 79 | > 这里假设:插件设置的端口号为 `16806`,授权码为 `abcdefg`,请以实际填写的插件设置为准。 80 | 81 | 修改MCP应用的配置,选择`Streamable HTTP`类型,并配置端点。 82 | 83 | ### 支持Streamable HTTP类型的客户端 84 | 85 | 下面的配置以 [Cherry Studio](https://github.com/CherryHQ/cherry-studio) 为例,针对不同的MCP客户端,可能需要不同的配置格式,请以MCP客户端文档为准。 86 | 87 | **插件未设置授权码** 88 | 89 | 1. 类型:选择 可流式传输的HTTP(streamablehttp); 90 | 2. URL:`http://127.0.0.1:16806/mcp`; 91 | 3. 请求头:空; 92 | 93 | **插件已设置授权码** 94 | 95 | 1. 类型:选择 可流式传输的HTTP(streamablehttp); 96 | 2. URL:`http://127.0.0.1:16806/mcp`; 97 | 3. 请求头:`Authorization=Bearer abcedfg`; 98 | 99 | > 这里假设:插件设置的端口号为 `16806`,授权码为 `abcdefg`,请以实际填写的插件设置为准。 100 | 101 | ### 仅支持stdio的客户端 102 | 103 | 若MCP客户端不支持基于HTTP的通信方式,仅支持stdio,则需要通过转换后使用。 104 | 105 | 这里使用`node.js` + `mcp-remote@next`的方案。 106 | 107 | 1. 下载nodejs https://nodejs.org/zh-cn/download 108 | 109 | 2. 安装mcp-remote@next 110 | ```bash 111 | npm install -g mcp-remote@next 112 | ``` 113 | 114 | 下面的配置以 [5ire](https://5ire.app/) 为例,针对不同的MCP客户端,可能需要不同的配置格式,请以MCP客户端文档为准。 115 | 116 | **插件未设置授权码** 117 | 118 | 命令: 119 | 120 | ``` 121 | npx mcp-remote@next http://127.0.0.1:16806/mcp 122 | ``` 123 | 124 | **插件已设置授权码** 125 | 126 | 命令: 127 | ``` 128 | npx mcp-remote@next http://127.0.0.1:16806/mcp --header Authorization:${AUTH_HEADER} 129 | ``` 130 | 131 | 环境变量: 132 | 133 | 名称:`AUTH_HEADER` 134 | 值:`Bearer abcdefg` 135 | 136 | > 这里假设:插件设置的端口号为 `16806`,授权码为 `abcdefg`,请以实际填写的插件设置为准。 137 | 138 | ## 🙏参考&感谢 139 | 140 | > 部分依赖项在`package.json`中列出。 141 | 142 | | 开发者/项目 | 项目描述 | 引用方式 | 143 | |---------------------------------------------------------------------|----------------|--------------| 144 | | [thuanpham582002/tabby-mcp-server](https://github.com/thuanpham582002/tabby-mcp-server) | 在终端软件Tabby中提供MCP服务; MIT License | MCP服务实现方式 | 145 | | [wilsons](https://ld246.com/article/1756172573626/comment/1756384424179?r=wilsons#comments) / [Frostime](https://ld246.com/article/1739546865001#%E6%80%9D%E6%BA%90-SQL-%E6%9F%A5%E8%AF%A2-System-Prompt) | 提示词/系统提示词 CC BY-SA 4.0 | 系统提示词等,位于项目的`static/`目录 | 146 | -------------------------------------------------------------------------------- /src/tools/search.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createJsonResponse, createSuccessResponse } from "../utils/mcpResponse"; 3 | import { DEFAULT_FILTER, fullTextSearchBlock } from "@/syapi"; 4 | import searchSyntax from "@/../static/query_syntax.md"; 5 | import { McpToolsProvider } from "./baseToolProvider"; 6 | import { formatSearchResult } from "@/utils/resultFilter"; 7 | import { debugPush, errorPush, isDebugMode, logPush } from "@/logger"; 8 | import { showMessage } from "siyuan"; 9 | import { lang } from "@/utils/lang"; 10 | import { FullTextSearchQuery } from "@/types/api"; 11 | 12 | export class SearchToolProvider extends McpToolsProvider { 13 | async getTools(): Promise[]> { 14 | return [];// # 16 15 | return [ 16 | { 17 | name: "siyuan_search", 18 | description: "Perform a keyword-based full-text search across blocks in SiYuan (e.g., paragraphs, headings). This tool only matches literal text content in document bodies or headings. For dynamic queries (dailynote(i.e. diary), path restrictions, date ranges), use sql with `siyuan_query_sql` tool instead. Results are grouped by their containing documents with limit page size 10.", 19 | schema: { 20 | query: z.string().describe("The keyword or phrase to search for across content blocks."), 21 | page: z.number().default(1).describe("The page number of the search results to return (starting from 1)."), 22 | includingCodeBlock: z.boolean().describe("Whether to include code blocks in the search results."), 23 | includingDatabase: z.boolean().describe("Whether to include database blocks in the search results."), 24 | method: z.number().default(0).describe("Search method: 0 for keyword search, 1 for query syntax (see `siyuan_query_syntax`), 2 for regular expression matching."), 25 | orderBy: z.number().default(0).describe(`Sorting method for results: 26 | 0: By block type (default) 27 | 1: By creation time (ascending) 28 | 2: By creation time (descending) 29 | 3: By update time (ascending) 30 | 4: By update time (descending) 31 | 5: By content order (only when grouped by document) 32 | 6: By relevance (ascending) 33 | 7: By relevance (descending) 34 | `), 35 | groupBy: z.number().default(1).describe(`Grouping method for results: 36 | 0: No grouping - returns individual blocks matching the search criteria 37 | 1: Group by document (default) - returns hits organized by their parent documents 38 | `), 39 | }, 40 | handler: searchHandler, 41 | title: lang("tool_title_search"), 42 | annotations: { 43 | readOnlyHint: true, 44 | }, 45 | }, 46 | { 47 | name: "siyuan_query_syntax", 48 | description: `Provides documentation about SiYuan's advanced query syntax for searching content blocks, including boolean operators (AND, OR, NOT).`, 49 | schema: {}, 50 | handler: querySyntaxHandler, 51 | title: lang("tool_title_query_syntax"), 52 | annotations: { 53 | readOnlyHint: true, 54 | }, 55 | }, 56 | ]; 57 | } 58 | } 59 | 60 | async function searchHandler(params, extra) { 61 | const { query, page, includingCodeBlock, includingDatabase, method, orderBy, groupBy } = params; 62 | debugPush("搜索工具被调用", params); 63 | const queryObj: FullTextSearchQuery = { 64 | query, 65 | page, 66 | types: DEFAULT_FILTER, 67 | orderBy, 68 | method, 69 | groupBy, 70 | }; 71 | queryObj.types.codeBlock = includingCodeBlock; 72 | queryObj.types.databaseBlock = includingDatabase; 73 | const response = await fullTextSearchBlock(queryObj); 74 | try { 75 | const result = formatSearchResult(response, queryObj); 76 | return createSuccessResponse(result); 77 | } catch (err) { 78 | errorPush("精简搜索API返回时出现问题", err); 79 | if (isDebugMode()) { 80 | showMessage("搜索API处理时出现问题"); 81 | } 82 | return createJsonResponse(response); 83 | } finally { 84 | debugPush("搜索工具调用结束"); 85 | } 86 | } 87 | 88 | async function querySyntaxHandler(params, extra) { 89 | return createSuccessResponse(searchSyntax); 90 | } -------------------------------------------------------------------------------- /src/tools/vectorSearch.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createErrorResponse, createJsonResponse, createSuccessResponse } from "../utils/mcpResponse"; 3 | import { McpToolsProvider } from "./baseToolProvider"; 4 | import { debugPush, errorPush, logPush } from "@/logger"; 5 | import { useConsumer, useProvider } from "@/utils/indexerHelper"; 6 | import { lang } from "@/utils/lang"; 7 | 8 | export class DocVectorSearchProvider extends McpToolsProvider { 9 | async getTools(): Promise[]> { 10 | const indexProvider = useProvider(); 11 | const healthResult = await indexProvider.health(); 12 | if (healthResult == null) { 13 | logPush("Connection with RAG backend ERROR: (RAG Tool will not be load to MCP server)", healthResult); 14 | return []; 15 | } 16 | const EXPORT_API = async (question)=>{ 17 | const provider = useProvider(); 18 | return await provider.query(question) 19 | }; 20 | if (window["OpaqueGlassAPI"]) { 21 | window["OpaqueGlassAPI"]["ragQuery"] = EXPORT_API; 22 | } else { 23 | window["OpaqueGlassAPI"] = { 24 | "ragQuery": EXPORT_API 25 | } 26 | } 27 | window["OpaqueGlassAPI"][""] 28 | return [{ 29 | name: "siyuan_generate_answer_with_doc", 30 | description: 'This tool provides a Retrieval-Augmented Generation (RAG) based Q&A capability. It generates context-aware answers using only the notes that the user has explicitly indexed from their siyuan-notes. Please note: the tool does not access or use all documents—only those that have been indexed by the user. ', 31 | schema: { 32 | question: z.string().describe("Describe question about note here"), 33 | }, 34 | handler: answerWithRAG, 35 | title: lang("tool_title_generate_answer_with_doc"), 36 | annotations: { 37 | readOnlyHint: false, 38 | destructiveHint: false, 39 | idempotentHint: false, 40 | } 41 | }]; 42 | } 43 | } 44 | 45 | async function answerWithRAG(params, extra) { 46 | const { question } = params; 47 | debugPush("API被调用:RAG"); 48 | const provider = useProvider(); 49 | let progressInterval; 50 | let timeoutId; 51 | let finished = false; 52 | const progressToken = extra?._meta?.progressToken; 53 | let currentProgress = 0; 54 | const maxDuration = 120 * 1000; // 120秒 55 | const updateInterval = 3000; // 3秒 56 | const progressIncrement = updateInterval / maxDuration; 57 | 58 | if (progressToken) { 59 | progressInterval = setInterval(() => { 60 | currentProgress += progressIncrement; 61 | if (currentProgress < 0.95) { 62 | extra.sendNotification && extra.sendNotification({ 63 | method: "notifications/progress", 64 | params: { progress: currentProgress, progressToken } 65 | }); 66 | } 67 | }, updateInterval); 68 | timeoutId = setTimeout(() => { 69 | if (!finished) { 70 | finished = true; 71 | clearInterval(progressInterval); 72 | extra.sendNotification && extra.sendNotification({ 73 | method: "notifications/progress", 74 | params: { progress: 1, progressToken } 75 | }); 76 | } 77 | }, maxDuration); 78 | } 79 | try { 80 | const resultPromise = provider.query(question); 81 | const result = await Promise.race([ 82 | resultPromise, 83 | new Promise((_, reject) => setTimeout(() => reject(new Error("RAG query timeout (120s)")), maxDuration)) 84 | ]); 85 | finished = true; 86 | if (progressInterval) clearInterval(progressInterval); 87 | if (timeoutId) clearTimeout(timeoutId); 88 | if (progressToken) { 89 | extra.sendNotification && extra.sendNotification({ 90 | method: "notifications/progress", 91 | params: { progress: 1, progressToken } 92 | }); 93 | } 94 | logPush("RAG result", result); 95 | return createJsonResponse(result); 96 | } catch (err) { 97 | finished = true; 98 | if (progressInterval) clearInterval(progressInterval); 99 | if (timeoutId) clearTimeout(timeoutId); 100 | if (progressToken) { 101 | extra.sendNotification && extra.sendNotification({ 102 | method: "notifications/progress", 103 | params: { progress: 1, progressToken } 104 | }); 105 | } 106 | errorPush("RAG API error", err); 107 | return createErrorResponse("The tool call failed. " + (err?.message || "There was a problem with the connection to the RAG service. Please remind the user to troubleshoot the problem.")); 108 | } 109 | } -------------------------------------------------------------------------------- /src/utils/eventHandler.ts: -------------------------------------------------------------------------------- 1 | import { debugPush, errorPush, infoPush, isDebugMode, logPush, warnPush } from "@/logger"; 2 | import {type IProtyle, type IEventBusMap, showMessage} from "siyuan"; 3 | import * as siyuanAPIs from "siyuan"; 4 | import { getAllShowingDocId, getHPathById, isMobile } from "@/syapi"; 5 | import { getPluginInstance } from "./pluginHelper"; 6 | import { useConsumer, useProvider, useQueue } from "./indexerHelper"; 7 | import { getSubDocIds } from "@/syapi/custom"; 8 | import { useWsIndexQueue } from "./wsMainHelper"; 9 | export default class EventHandler { 10 | private handlerBindList: Recordvoid> = { 11 | "ws-main": this.wsMainHandler.bind(this), 12 | "open-menu-doctree": this.openMenuDocTreeHandler.bind(this) 13 | }; 14 | // 关联的设置项,如果设置项对应为true,则才执行绑定 15 | private relateGsettingKeyStr: Record = { 16 | "loaded-protyle-static": null, // mutex需要访问EventHandler的属性 17 | "switch-protyle": null, 18 | "ws-main": null, 19 | }; 20 | 21 | private simpleMutex: number = 0; 22 | private docIdMutex: Record = {}; 23 | constructor() { 24 | } 25 | 26 | bindHandler() { 27 | const plugin = getPluginInstance(); 28 | const g_setting = plugin.mySettings; 29 | logPush("binding") 30 | if (!isDebugMode()) { 31 | logPush("非debug模式,不加入"); 32 | return; 33 | } 34 | // const g_setting = getReadOnlyGSettings(); 35 | for (let key in this.handlerBindList) { 36 | if (this.relateGsettingKeyStr[key] == null || g_setting[this.relateGsettingKeyStr[key]]) { 37 | plugin.eventBus.on(key, this.handlerBindList[key]); 38 | } 39 | } 40 | } 41 | 42 | unbindHandler() { 43 | const plugin = getPluginInstance(); 44 | for (let key in this.handlerBindList) { 45 | plugin.eventBus.off(key, this.handlerBindList[key]); 46 | } 47 | } 48 | 49 | async wsMainHandler(detail: CustomEvent){ 50 | const cmdTypeD = { 51 | "databaseIndexCommit": ()=>{ 52 | useWsIndexQueue()?.signalOne(); 53 | } 54 | }; 55 | if (cmdTypeD[detail.detail.cmd]) { 56 | cmdTypeD[detail.detail.cmd](); 57 | } 58 | } 59 | async openMenuDocTreeHandler(event: CustomEvent) { 60 | logPush("data", event.detail); 61 | const provider = useProvider(); 62 | if (event.detail.type !== "notebook") { 63 | if (event.detail.menu.menus && event.detail.menu.menus.length >= 1) { 64 | event.detail.menu.addSeparator(); 65 | } 66 | event.detail.menu.addItem({ 67 | "label": "对所选文档进行索引", 68 | "click": (element, mouseEvent)=>{ 69 | const idList = [].map.call(event.detail.elements, (item)=>item.getAttribute("data-node-id")); 70 | const queue = useQueue(); 71 | if (queue) { 72 | logPush("ids", idList); 73 | queue.batchAddToQueue(idList.map(item=>{return {"id": item}})).then(()=>{ 74 | useConsumer()?.consume(); 75 | });; 76 | logPush("Docs added", idList.length); 77 | } 78 | } 79 | }); 80 | event.detail.menu.addItem({ 81 | "label": "对所选文档及其下层文档进行索引", 82 | "click": (element, mouseEvent)=>{ 83 | let parentIdList = [].map.call(event.detail.elements, (item)=>item.getAttribute("data-node-id")); 84 | const resultIds = []; 85 | resultIds.push(...parentIdList); 86 | const handleSubIds = async (id)=>{ 87 | try { 88 | const subDocIds = await getSubDocIds(id); 89 | if (subDocIds != null && subDocIds.length > 0) { 90 | resultIds.push(...subDocIds); 91 | } 92 | } catch (err) { 93 | debugPush("无子文档或其他错误", err); 94 | } 95 | }; 96 | const idsPromise = parentIdList.map(item=>handleSubIds(item)); 97 | Promise.all(idsPromise).then((item)=>{ 98 | const queue = useQueue(); 99 | if (queue) { 100 | logPush("ids", resultIds); 101 | queue.batchAddToQueue(resultIds.map(item=>{return {"id": item}})).then(()=>{ 102 | useConsumer()?.consume(); 103 | }); 104 | logPush("Docs added", resultIds.length); 105 | } 106 | }); 107 | 108 | } 109 | }); 110 | // event.detail.menu.addItem({ 111 | // "lable": "移除索引", 112 | // "click": (e)=>{ 113 | 114 | // } 115 | // }) 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /src/tools/attributes.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createErrorResponse, createJsonResponse, createSuccessResponse } from "../utils/mcpResponse"; 3 | import { addblockAttrAPI, getblockAttr } from "@/syapi"; 4 | import { getBlockDBItem } from "@/syapi/custom"; 5 | import { McpToolsProvider } from "./baseToolProvider"; 6 | import { isValidStr } from "@/utils/commonCheck"; 7 | 8 | import { lang } from "@/utils/lang"; 9 | import { filterBlock } from "@/utils/filterCheck"; 10 | 11 | export class AttributeToolProvider extends McpToolsProvider { 12 | async getTools(): Promise[]> { 13 | return [ 14 | { 15 | name: "siyuan_set_block_attributes", 16 | description: "Set, update, or delete attributes for a specific block. To delete an attribute, set its value to an empty string.", 17 | schema: { 18 | blockId: z.string().describe("The ID of the block to modify."), 19 | attributes: z.record(z.string()).describe("An object of key-value pairs representing the attributes to set. Setting an attribute to an empty string ('') will delete it."), 20 | }, 21 | handler: setBlockAttributesHandler, 22 | title: lang("tool_title_set_block_attributes"), 23 | annotations: { 24 | readOnlyHint: false, 25 | destructiveHint: true, // Can delete attributes 26 | idempotentHint: false, 27 | } 28 | }, 29 | { 30 | name: "siyuan_get_block_attributes", 31 | description: "Get all attributes of a specific block.", 32 | schema: { 33 | blockId: z.string().describe("The ID of the block to get attributes from."), 34 | }, 35 | handler: getBlockAttributesHandler, 36 | title: lang("tool_title_get_block_attributes"), 37 | annotations: { 38 | readOnlyHint: true, 39 | } 40 | } 41 | ]; 42 | } 43 | } 44 | 45 | async function setBlockAttributesHandler(params, extra) { 46 | const { blockId, attributes } = params; 47 | 48 | if (!isValidStr(blockId)) { 49 | return createErrorResponse("blockId cannot be empty."); 50 | } 51 | 52 | const dbItem = await getBlockDBItem(blockId); 53 | if (dbItem == null) { 54 | return createErrorResponse("Invalid document or block ID. Please check if the ID exists and is correct."); 55 | } 56 | 57 | if (await filterBlock(blockId, dbItem)) { 58 | return createErrorResponse("The specified block is excluded by the user settings. Can't read or write."); 59 | } 60 | 61 | if (typeof attributes !== 'object' || attributes === null) { 62 | return createErrorResponse("attributes must be an object."); 63 | } 64 | 65 | const allowedNonCustomKeys = ['name', 'alias', 'memo', 'bookmark']; 66 | const customKeyRegex = /^[a-zA-Z0-9]+$/; 67 | 68 | for (const key in attributes) { 69 | if (key.startsWith('custom-')) { 70 | const customPart = key.substring('custom-'.length); 71 | if (!customKeyRegex.test(customPart)) { 72 | return createErrorResponse(`Invalid custom attribute name: '${key}'. The part after 'custom-' must only contain letters and(or) numbers.`); 73 | } 74 | } else if (!allowedNonCustomKeys.includes(key)) { 75 | return createErrorResponse(`Invalid attribute name: '${key}'. Attribute names must start with 'custom-' or be one of the following: ${allowedNonCustomKeys.join(', ')}.`); 76 | } 77 | if (typeof attributes[key] !== 'string') { 78 | return createErrorResponse(`Invalid value for attribute '${key}'. Attribute values must be strings.`); 79 | } 80 | } 81 | 82 | try { 83 | const result = await addblockAttrAPI(attributes, blockId); 84 | if (result === 0) { 85 | return createSuccessResponse("Attributes updated successfully."); 86 | } else { 87 | return createErrorResponse("Failed to update attributes."); 88 | } 89 | } catch (error) { 90 | return createErrorResponse(`An error occurred: ${error.message}`); 91 | } 92 | } 93 | 94 | async function getBlockAttributesHandler(params, extra) { 95 | const { blockId } = params; 96 | 97 | if (!isValidStr(blockId)) { 98 | return createErrorResponse("blockId cannot be empty."); 99 | } 100 | 101 | const dbItem = await getBlockDBItem(blockId); 102 | if (dbItem == null) { 103 | return createErrorResponse("Invalid document or block ID. Please check if the ID exists and is correct."); 104 | } 105 | if (await filterBlock(blockId, dbItem)) { 106 | return createErrorResponse("The specified block is excluded by the user settings. Can't read or write."); 107 | } 108 | 109 | try { 110 | const attributes = await getblockAttr(blockId); 111 | // The API returns an empty object if there are no attributes, which is a valid JSON response. 112 | return createJsonResponse(attributes ?? {}); 113 | } catch (error) { 114 | return createErrorResponse(`An error occurred: ${error.message}`); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/tools/relation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createErrorResponse, createJsonResponse, createSuccessResponse } from "../utils/mcpResponse"; 3 | import { getBackLink2T, getChildBlocks, getNodebookList, listDocsByPathT, listDocTree } from "@/syapi"; 4 | import { McpToolsProvider } from "./baseToolProvider"; 5 | import { debugPush, logPush, warnPush } from "@/logger"; 6 | import { getBlockDBItem, getChildDocumentIds, getDocDBitem, getSubDocIds } from "@/syapi/custom"; 7 | import { filterBlock } from "@/utils/filterCheck"; 8 | 9 | export class RelationToolProvider extends McpToolsProvider { 10 | async getTools(): Promise[]> { 11 | return [{ 12 | name: "siyuan_get_doc_backlinks", 13 | description: "Retrieve all documents or blocks that reference a specified document or block within the workspace. The result includes the referencing document's ID, name, notebook ID, and path. Useful for understanding backlinks and document relationships within the knowledge base.", 14 | schema: { 15 | id: z.string().describe("The ID of the target document or block. The notebook where the target resides must be open."), 16 | }, 17 | handler: getDocBacklink, 18 | title: "Get Note Relationship", 19 | annotations: { 20 | readOnlyHint: true, 21 | destructiveHint: false, 22 | idempotentHint: false, 23 | } 24 | },{ 25 | "name": "siyuan_list_sub_docs", 26 | "description": "Retrieve the basic information of sub-documents under a specified document within the SiYuan workspace. Useful for analyzing document structure and hierarchy relationships.", 27 | "schema": { 28 | "id": z.string().describe("The ID of the parent document or notebook. The notebook containing this document must be open."), 29 | }, 30 | "handler": getChildrenDocs, 31 | "title": "Get Sub-Document Information", 32 | "annotations": { 33 | "readOnlyHint": true, 34 | "destructiveHint": false, 35 | "idempotentHint": true 36 | } 37 | },{ 38 | "name": "siyuan_get_children_blocks", 39 | "description": "根据父块的 ID,获取其下方的所有子块列表。这包括直接嵌套的块以及标题下方的块。过长的块内容将被省略、仅提供预览。有助于理解块的层级结构和内容组织。", 40 | "schema": { 41 | "id": z.string().describe("父块的唯一标识符(ID)。") 42 | }, 43 | "handler": getChildBlocksTool, 44 | "title": "获取子块列表", 45 | "annotations": { 46 | "readOnlyHint": true, 47 | "destructiveHint": false, 48 | "idempotentHint": true 49 | } 50 | }] 51 | } 52 | } 53 | 54 | async function getDocBacklink(params, extra) { 55 | const {id} = params; 56 | const dbItem = await getBlockDBItem(id); 57 | if (dbItem == null) { 58 | return createErrorResponse("Invalid document or block ID. Please check if the ID exists and is correct."); 59 | } 60 | if (await filterBlock(id, dbItem)) { 61 | return createErrorResponse("The specified document or block is excluded by the user settings. So cannot write or read. "); 62 | } 63 | const backlinkResponse = await getBackLink2T(id, "3"); 64 | debugPush("backlinkResponse", backlinkResponse); 65 | if (backlinkResponse.backlinks.length == 0) { 66 | return createSuccessResponse("No documents or blocks referencing the specified ID were found."); 67 | } 68 | const result = []; 69 | for (let i = 0; i < backlinkResponse.backlinks.length; i++) { 70 | const oneBacklinkItem = backlinkResponse.backlinks[i]; 71 | if (oneBacklinkItem.nodeType === "NodeDocument") { 72 | let tempDocItem = { 73 | "name": oneBacklinkItem.name, 74 | "id": oneBacklinkItem.id, 75 | "notebookId": oneBacklinkItem.box, 76 | "hpath": oneBacklinkItem.hpath, 77 | }; 78 | result.push(tempDocItem); 79 | } 80 | } 81 | return createJsonResponse(result); 82 | } 83 | 84 | async function getChildrenDocs(params, extra) { 85 | const { id } = params; 86 | const notebookList = await getNodebookList(); 87 | const notebookIds = notebookList.map(item=>item.id); 88 | const sqlResult = await getDocDBitem(id); 89 | let result = null; 90 | if (await filterBlock(id, sqlResult)) { 91 | return createErrorResponse("The specified document or block is excluded by the user settings. So cannot write or read. "); 92 | } 93 | if (sqlResult == null && !notebookIds.includes(id)) { 94 | return createErrorResponse("所查询的id不存在,或不对应笔记文档与笔记本,请检查输入的id是否正确有效"); 95 | } else if (sqlResult == null) { 96 | result = await listDocsByPathT({notebook: id, path: "/"}); 97 | } else { 98 | result = await listDocsByPathT({notebook: sqlResult["box"], path: sqlResult["path"]}); 99 | } 100 | return createJsonResponse(result); 101 | } 102 | 103 | async function getChildBlocksTool(params, extra) { 104 | const { id } = params; 105 | const sqlResult = await getBlockDBItem(id); 106 | if (sqlResult == null) { 107 | return createErrorResponse("Invalid document or block ID. Please check if the ID exists and is correct."); 108 | } 109 | if (await filterBlock(id, sqlResult)) { 110 | return createErrorResponse("The specified document or block is excluded by the user settings. So cannot write or read. "); 111 | } 112 | return createJsonResponse(await getChildBlocks(id)); 113 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | const webpack = require("webpack"); 4 | const {EsbuildPlugin} = require("esbuild-loader"); 5 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 6 | const CopyPlugin = require("copy-webpack-plugin"); 7 | const ZipPlugin = require("zip-webpack-plugin"); 8 | const {VueLoaderPlugin} = require('vue-loader') 9 | 10 | // 使用change-dir命令更改dev的目录 11 | const devDistDirInfo = "./scripts/devInfo.json"; 12 | const loadDirJsonContent = fs.existsSync(devDistDirInfo) 13 | ? JSON.parse(fs.readFileSync(devDistDirInfo, "utf-8")) 14 | : {}; 15 | const devDistDir = loadDirJsonContent["devDir"] ?? "./dev"; 16 | const distDir = devDistDir 17 | 18 | module.exports = (env, argv) => { 19 | const isPro = argv.mode === "production"; 20 | const plugins = [ 21 | new VueLoaderPlugin(), 22 | new MiniCssExtractPlugin({ 23 | filename: isPro ? "dist/index.css" : "index.css", 24 | }) 25 | ]; 26 | let entry = { 27 | "index": "./src/index.ts", 28 | }; 29 | if (isPro) { 30 | entry = { 31 | "dist/index": "./src/index.ts", 32 | }; 33 | plugins.push(new webpack.BannerPlugin({ 34 | banner: () => { 35 | return fs.readFileSync("LICENSE").toString(); 36 | }, 37 | })); 38 | plugins.push(new CopyPlugin({ 39 | patterns: [ 40 | {from: "preview.png", to: "./dist/"}, 41 | {from: "icon.png", to: "./dist/"}, 42 | {from: "README*.md", to: "./dist/"}, 43 | {from: "plugin.json", to: "./dist/"}, 44 | {from: "LICENSE", to: "./dist/"}, 45 | {from: "CHANGELOG.md", to: "./dist/"}, 46 | {from: "src/i18n/", to: "./dist/i18n/"}, 47 | {from: "static", to: "./dist/static/"}, 48 | ], 49 | })); 50 | plugins.push(new ZipPlugin({ 51 | filename: "package.zip", 52 | algorithm: "gzip", 53 | include: [/dist/], 54 | pathMapper: (assetPath) => { 55 | return assetPath.replace("dist/", ""); 56 | }, 57 | })); 58 | } else { 59 | plugins.push(new CopyPlugin({ 60 | patterns: [ 61 | {from: "src/i18n/", to: "./i18n/"}, 62 | {from: "static/", to: "./static/"}, 63 | {from: "preview.png", to: "."}, 64 | {from: "icon.png", to: "."}, 65 | {from: "README*.md", to: "."}, 66 | {from: "plugin.json", to: ""}, 67 | {from: "LICENSE", to: "."}, 68 | ], 69 | })); 70 | } 71 | return { 72 | mode: argv.mode || "development", 73 | watch: !isPro, 74 | devtool: isPro ? false : "eval", 75 | target: "electron-renderer", 76 | output: { 77 | filename: "[name].js", 78 | path: isPro ? path.resolve(__dirname) : distDir,//path.resolve(__dirname, 'dist'), 79 | libraryTarget: "commonjs2", 80 | library: { 81 | type: "commonjs2", 82 | }, 83 | }, 84 | externals: { 85 | siyuan: "siyuan", 86 | }, 87 | entry, 88 | optimization: { 89 | minimize: true, 90 | minimizer: [ 91 | new EsbuildPlugin(), 92 | ], 93 | }, 94 | resolve: { 95 | extensions: [".ts", ".scss", ".js", ".json", ".vue"], 96 | alias: { 97 | "@": path.resolve(__dirname, "src"), 98 | // 'vue$': 'vue/dist/vue.esm-bundler.js' 99 | }, 100 | }, 101 | module: { 102 | rules: [ 103 | { 104 | test: /\.vue$/, 105 | use: 'vue-loader' 106 | }, 107 | { 108 | test: /\.ts(x?)$/, 109 | include: [path.resolve(__dirname, "src")], 110 | use: [ 111 | { 112 | loader: "esbuild-loader", 113 | options: { 114 | target: "es6", 115 | loader: 'ts' 116 | } 117 | }, 118 | ], 119 | }, 120 | // { 121 | // test: /\.ts$/, 122 | // loader: 'ts-loader', 123 | // options: { 124 | // appendTsSuffixTo: [/\.vue$/] 125 | // } 126 | // }, 127 | { 128 | test: /\.scss$/, 129 | include: [path.resolve(__dirname, "src")], 130 | use: [ 131 | MiniCssExtractPlugin.loader, 132 | { 133 | loader: "css-loader", // translates CSS into CommonJS 134 | }, 135 | { 136 | loader: "sass-loader", // compiles Sass to CSS 137 | }, 138 | ], 139 | }, 140 | { 141 | test: /\.css$/, 142 | use: ['style-loader', 'css-loader'] 143 | }, 144 | { 145 | test: /\.md$/, 146 | include: [path.resolve(__dirname, "static")], 147 | use: 'raw-loader', 148 | } 149 | ], 150 | }, 151 | plugins, 152 | }; 153 | }; 154 | -------------------------------------------------------------------------------- /src/utils/historyTaskHelper.ts: -------------------------------------------------------------------------------- 1 | import { getJSONFile, putJSONFile } from "@/syapi"; 2 | 3 | const TASKS_FILE_PATH = '/data/storage/petal/syplugin-anMCPServer/tasks.json'; 4 | 5 | // 任务状态常量 6 | export const TASK_STATUS = { 7 | PENDING: 0, // 待审阅 8 | APPROVED: 1, // 已批准 9 | REJECTED: -1 // 已拒绝 10 | }; 11 | 12 | class TaskManager { 13 | private filePath; 14 | private tasks; 15 | private nextId; 16 | constructor(filePath = TASKS_FILE_PATH) { 17 | this.filePath = filePath; 18 | this.tasks = []; 19 | this.nextId = 1; 20 | } 21 | 22 | /** 23 | * 初始化:从文件加载任务数据 24 | */ 25 | async init() { 26 | const data = await getJSONFile(this.filePath); 27 | if (data && data.tasks) { 28 | this.tasks = data.tasks; 29 | // 确保 nextId 不会重复 30 | if (this.tasks.length > 0) { 31 | this.nextId = Math.max(...this.tasks.map(t => t.id)) + 1; 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * 持久化任务数据到文件 38 | */ 39 | async #save() { 40 | await putJSONFile(this.filePath, { tasks: this.tasks }, false); 41 | } 42 | 43 | /** 44 | * 插入一个新任务 45 | * @param {string[]} ids - 任务修改内容的唯一ID数组 46 | * @param {object} content - 修改后的内容 47 | * @param {string} taskType - 任务类型的唯一名称 48 | * @param {object} args - 任务的其他参数 49 | * @param {number} status - 任务状态 50 | * @returns {number} 新任务的唯一ID 51 | */ 52 | async insert(ids, content, taskType, args, status = TASK_STATUS.PENDING) { 53 | const taskId = this.nextId++; 54 | const newTask = { 55 | id: taskId, 56 | modifiedIds: Array.isArray(ids) ? ids : [ids], 57 | content: content, 58 | taskType: taskType, 59 | args: args, 60 | status: status, 61 | createdAt: new Date().toISOString(), 62 | updatedAt: new Date().toISOString() 63 | }; 64 | this.tasks.push(newTask); 65 | await this.#save(); 66 | return taskId; 67 | } 68 | 69 | /** 70 | * 获取任务 71 | * @param {number} taskId - 任务ID 72 | * @returns {object|null} 任务对象 73 | */ 74 | #getTaskById(taskId) { 75 | return this.tasks.find(task => task.id === taskId); 76 | } 77 | 78 | /** 79 | * 标记任务为已批准 80 | * @param {number} taskId - 任务ID 81 | */ 82 | async solve(taskId) { 83 | const task = this.#getTaskById(taskId); 84 | if (task) { 85 | // RedoTask 86 | task.status = TASK_STATUS.APPROVED; 87 | task.updatedAt = new Date().toISOString(); 88 | await this.#save(); 89 | } 90 | } 91 | 92 | /** 93 | * 标记任务为已拒绝 94 | * @param {number} taskId - 任务ID 95 | */ 96 | async reject(taskId) { 97 | const task = this.#getTaskById(taskId); 98 | if (task) { 99 | task.status = TASK_STATUS.REJECTED; 100 | task.updatedAt = new Date().toISOString(); 101 | await this.#save(); 102 | } 103 | } 104 | 105 | /** 106 | * 批量拒绝所有待审阅任务 107 | */ 108 | async rejectAll() { 109 | this.tasks.forEach(task => { 110 | if (task.status === TASK_STATUS.PENDING) { 111 | task.status = TASK_STATUS.REJECTED; 112 | task.updatedAt = new Date().toISOString(); 113 | } 114 | }); 115 | await this.#save(); 116 | } 117 | 118 | /** 119 | * 列出所有任务 120 | * @param {string} sortOrder - 排序方式, 'asc' 或 'desc' 121 | * @returns {Array} 任务列表 122 | */ 123 | listAll(sortOrder = 'desc') { 124 | const sortedTasks = [...this.tasks].sort((a, b) => { 125 | const dateA = new Date(a.createdAt).getTime(); 126 | const dateB = new Date(b.createdAt).getTime(); 127 | return sortOrder === 'asc' ? dateA - dateB : dateB - dateA; 128 | }); 129 | return sortedTasks; 130 | } 131 | 132 | /** 133 | * 列出未被批准的任务 134 | * @param {string} sortOrder - 排序方式, 'asc' 或 'desc' 135 | * @returns {Array} 任务列表 136 | */ 137 | list(sortOrder = 'desc') { 138 | const pendingTasks = this.tasks.filter(task => task.status === TASK_STATUS.PENDING); 139 | const sortedPendingTasks = pendingTasks.sort((a, b) => { 140 | const dateA = new Date(a.createdAt).getTime(); 141 | const dateB = new Date(b.createdAt).getTime(); 142 | return sortOrder === 'asc' ? dateA - dateB : dateB - dateA; 143 | }); 144 | return sortedPendingTasks; 145 | } 146 | 147 | /** 148 | * 清理任务 149 | * @param {number} days - 清理几天前的任务 150 | * @param {boolean} cleanUnapproved - 是否清理未被批准的任务 151 | */ 152 | async clean(days, cleanUnapproved = false) { 153 | const cutoffDate = new Date(); 154 | cutoffDate.setDate(cutoffDate.getDate() - days); 155 | 156 | this.tasks = this.tasks.filter(task => { 157 | const isOld = new Date(task.createdAt) < cutoffDate; 158 | const isUnapproved = task.status === TASK_STATUS.PENDING; 159 | 160 | // 如果任务是旧的 161 | if (isOld) { 162 | // 并且是未批准的,但 cleanUnapproved 选项为 false,则保留 163 | if (isUnapproved && !cleanUnapproved) { 164 | return true; 165 | } 166 | // 否则删除 167 | return false; 168 | } 169 | // 如果任务不旧,则保留 170 | return true; 171 | }); 172 | 173 | await this.#save(); 174 | } 175 | 176 | // 获取指定任务 177 | getTask(taskId) { 178 | return this.#getTaskById(taskId); 179 | } 180 | } 181 | 182 | export const taskManager = new TaskManager(); 183 | taskManager.init(); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # A little MCP server for siyuan-note 3 | 4 | [中文](./README_zh_CN.md) 5 | 6 | > A plugin that provides MCP service for [Siyuan Note](https://github.com/siyuan-note/siyuan). 7 | 8 | > ⚠️ Breaking changes: Upgrading from v0.1.x to v0.2.x introduces breaking changes. [CHANGELOG_zh-CN](./CHANGELOG.md) 9 | 10 | ## ✨ Quick Start 11 | 12 | - Download from the marketplace or 1. unzip the `package.zip` in Release, 2. move the folder to `workspace/data/plugins/`, 3. and rename the folder to `syplugin-anMCPServer`; 13 | - Enable the plugin; 14 | - The plugin listens on port `16806` by default (Host: `127.0.0.1`), please use `http://127.0.0.1:16806/sse` as the server access address; 15 | 16 | > ⭐ If this is helpful to you, please consider giving it a star! 17 | 18 | ## 🔧 Supported Tools 19 | 20 | * **\[Search]** 21 | 22 | * ~~Keyword search;~~ Temporarily removed, please provide feedback if needed 23 | * SQL search; 24 | * Notebook index Q\&A (using RAG backend service, [feature in testing](./RAG_BETA.md)); 25 | 26 | * **\[Retrieve]** 27 | 28 | * Get document kramdown by ID; 29 | * List notebooks; 30 | * Get backlinks by ID; 31 | * Get child document IDs; 32 | * Read properties; 33 | * ~~Read journal entries by date;~~ Temporarily removed, please provide feedback if needed 34 | 35 | * **\[Write]** 36 | 37 | * **Document type** 38 | 39 | * Append content to journal; 40 | * Append content to a document by ID; 41 | * Create a new document at a specified location by ID; 42 | * **Flashcard type** 43 | 44 | * Create flashcards from Markdown content; 45 | * Create flashcards by block ID; 46 | * Delete flashcards by block ID; 47 | * **Properties** 48 | 49 | * Modify properties; 50 | 51 | 52 | ## ❓ FAQ 53 | 54 | - Q: How to use it in an MCP client? 55 | Please refer to the later sections; 56 | 57 | - Q: What are some common MCP clients? 58 | - Refer to: https://github.com/punkpeye/awesome-mcp-clients or https://modelcontextprotocol.io/clients; 59 | 60 | - Q: Does the plugin support authentication? 61 | - Version v0.2.0 now supports authentication. After setting the authentication token in the plugin settings, the MCP client needs to configure the `authorization` request header with the value `Bearer YourToken`; 62 | 63 | - Q: Can it be used in Docker? 64 | - No, the plugin relies on a Node.js environment and does not support running on mobile devices or Docker. 65 | 66 | > To support SiYuan deployed in Docker, it is recommended to switch to other MCP projects. Some relevant projects may be listed [here](https://github.com/siyuan-note/siyuan/issues/13795). 67 | > 68 | > Alternatively, decouple this plugin from the SiYuan frontend. 69 | 70 | ## How to Configure in an MCP Client? 71 | 72 | > Different MCP clients require different configuration methods. Please refer to the MCP client documentation. 73 | > 74 | > MCP clients are continuously updated, so the configuration or usage instructions here may not be directly applicable and are for reference only. 75 | > 76 | > Here, we assume: the plugin’s port is `16806`, and the authorization token is `abcdefg`. 77 | 78 | Modify the MCP application’s configuration, select the `Streamable HTTP` type, and configure the endpoint. 79 | 80 | ### Clients Supporting Streamable HTTP 81 | 82 | The following configuration uses [Cherry Studio](https://github.com/CherryHQ/cherry-studio) as an example. Different MCP clients may require different configuration formats—please refer to the MCP client documentation. 83 | 84 | **Plugin Without Authorization Token** 85 | 86 | 1. Type: Select Streamable HTTP (`streamablehttp`); 87 | 2. URL: `http://127.0.0.1:16806/mcp`; 88 | 3. Headers: Leave empty; 89 | 90 | **Plugin With Authorization Token** 91 | 92 | 1. Type: Select Streamable HTTP (`streamablehttp`); 93 | 2. URL: `http://127.0.0.1:16806/mcp`; 94 | 3. Headers: `Authorization=Bearer abcedfg`; 95 | 96 | ### Clients Supporting Only Stdio 97 | 98 | If the MCP client does not support HTTP-based communication and only supports stdio, a conversion method is needed. 99 | 100 | Here, we use `node.js` + `mcp-remote@next`. 101 | 102 | 1. Download Node.js: https://nodejs.org/en/download 103 | 104 | 2. Install `mcp-remote@next`: 105 | ```bash 106 | npm install -g mcp-remote@next 107 | ``` 108 | 109 | The following configuration uses [5ire](https://5ire.app/) as an example. Different MCP clients may require different configuration formats—please refer to the MCP client documentation. 110 | 111 | **Plugin Without Authorization Token** 112 | 113 | Command: 114 | ``` 115 | npx mcp-remote@next http://127.0.0.1:16806/mcp 116 | ``` 117 | 118 | **Plugin With Authorization Token** 119 | 120 | Command: 121 | ``` 122 | npx mcp-remote@next http://127.0.0.1:16806/mcp --header Authorization:${AUTH_HEADER} 123 | ``` 124 | 125 | Environment Variable: 126 | 127 | Name: `AUTH_HEADER` 128 | Value: `Bearer abcdefg` 129 | 130 | ## 🙏 References & Acknowledgements 131 | 132 | > Some dependencies are listed in `package.json`. 133 | 134 | | Developer/Project | Project Description | Citation | 135 | |---------------------------------------------------------------------|----------------|--------------| 136 | | [thuanpham582002/tabby-mcp-server](https://github.com/thuanpham582002/tabby-mcp-server) | Provides MCP service within the terminal software Tabby; MIT License | Implementation method of MCP service | 137 | | [wilsons](https://ld246.com/article/1756172573626/comment/1756384424179?r=wilsons#comments) / [Frostime](https://ld246.com/article/1739546865001#%E6%80%9D%E6%BA%90-SQL-%E6%9F%A5%E8%AF%A2-System-Prompt) | System Prompt CC BY-SA 4.0 | System Prompts etc. which locate at `static/` | -------------------------------------------------------------------------------- /src/i18n/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugin_name": "一个MCP服务插件", 3 | "removeSpace": "移除空格", 4 | "start_error": "MCP服务启动失败,", 5 | "port_error": "端口已被占用,", 6 | "server_stop_error": "关闭服务时出错:", 7 | "server_running_on": "MCP服务运行在(端口): ", 8 | "sse_warning": "正在通过已弃用的方式(SSE)连接到MCP服务,推荐参考README.md或集市下载页重新配置。", 9 | "code_warning": "无效的访问授权码,请按照格式要求设置", 10 | "code_encrypted": "授权码已设置", 11 | 12 | "setting_port": "端口", 13 | "setting_port_desp": "MCP服务占用的端口,修改后需要先保存设置,再启动服务。应用中填写:http://127.0.0.1:端口号/mcp 。请参考集市下载页或README.md帮助文档。", 14 | "setting_autoStart": "自动启动", 15 | "setting_autoStart_desp": "控制是否随思源启动MCP服务", 16 | "setting_auth": "(客户端连入控制)授权码/token", 17 | "setting_auth_desp": "授权码用于验证MCP服务访问权限,需包含5位以上字母、数字或+-/._~符号,设为N/A可禁用验证;授权码会哈希存储且生效后不可查看,详情请参考集市下载页或README.md文档。", 18 | "setting_rag_baseurl": "插件RAG后端:请求baseURL", 19 | "setting_rag_baseurl_desp": "(此功能即将被移除,阅读README.md了解更多信息)(实验性功能!)插件会自动加入`/api/v1`后缀。如要了解使用方式,请阅读插件仓库 RAG_BETA.md", 20 | "setting_control": "控制", 21 | "setting_control_desp": "启动或停止MCP服务", 22 | "setting_control_start": "开启服务", 23 | "setting_control_stop": "关闭服务", 24 | "setting_readOnly": "“只读”模式", 25 | "setting_readOnly_desp": "控制工具的权限级别。修改后,如果当前MCP服务已在运行,请先保存设置项,再进入设置、手动重启MCP服务才能使得改动生效。开发者可能有遗漏,此设置项将仅最大努力禁用修改类型的工具,但不保证效果;", 26 | "setting_readOnly_allow_all": "允许所有修改", 27 | "setting_readOnly_allow_non_destructive": "仅允许非破坏性修改", 28 | "setting_readOnly_deny_all": "禁止任何修改(只读)", 29 | "setting_autoApproveLocalChange": "自动批准原地修改", 30 | "setting_autoApproveLocalChange_desp": "开启后,原地更改操作(updateBlock)将自动批准,无需人工审核。", 31 | "setting_filterDocId": "排除文档", 32 | "setting_filterDocId_desp": "每行填写一个文档ID,插件会基于这里的设置,停止对这些文档(或这些文档的任何子文档)的读写。最多支持填写100个条目。【注意:部分工具或部分情况不会进行排除,例如SQL查询,详情请阅读 TOOL_INFO.md】", 33 | "setting_filterNotebookId": "排除笔记本", 34 | "setting_filterNotebookId_desp": "每行填写一个笔记本ID,插件会基于这里的设置,停止对指定笔记本中文档的读写。最多支持填写50个条目。【注意:部分工具或部分情况不会进行排除,例如SQL查询,详情请阅读 TOOL_INFO.md】", 35 | "setting_status": "状态", 36 | "setting_status_connection": "连接数", 37 | "setting_status_open": "✅运行", 38 | "setting_status_close": "❌停止", 39 | "setting_status_refresh": "刷新状态", 40 | "setting_status_desp": "显示连接状态", 41 | "setting_copyright": "关于", 42 | "setting_copyright_desp": "作者:OpaqueGlass
问题反馈&赞助支持:github填写问卷
开源/免责声明:插件基于AGPLv3授权使用;
使用前,请阅读“README.md”说明文档或集市下载页说明文档。", 43 | "tool_append_dailynote": "将Markdown内容追加到思源笔记中今天指定笔记本的每日笔记(也称为日记、今日笔记)里。\n此工具通常用于自动将信息、想法或总结记录到每日笔记(日记)页面中。\n只有打开状态的笔记本(不是关闭状态)才是有效的追加目标。", 44 | "tool_get_current_time": "获取当前时间,包括年、月、日、时、分、秒、星期几等相关信息。", 45 | "tool_title_set_block_attributes": "设置块属性", 46 | "tool_title_get_block_attributes": "获取块属性", 47 | "tool_title_append_to_dailynote": "追加到日记", 48 | "tool_title_list_notebook": "列出笔记本", 49 | "tool_title_read_dailynote": "读取日记", 50 | "tool_title_read_doc_content_markdown": "读取文档内容(Markdown)", 51 | "tool_title_append_markdown_to_doc": "追加Markdown到文档", 52 | "tool_title_create_new_note_with_markdown_content": "创建带Markdown内容的新笔记", 53 | "tool_title_create_flashcards_with_new_doc": "在新文档中创建闪卡", 54 | "tool_title_create_flashcards": "创建闪卡", 55 | "tool_title_delete_flashcards": "删除闪卡", 56 | "tool_title_get_doc_backlinks": "获取文档反向链接", 57 | "tool_title_get_sub_doc_ids": "获取子文档ID", 58 | "tool_title_search": "搜索", 59 | "tool_title_query_syntax": "获取查询语法", 60 | "tool_title_database_schema": "获取数据库结构", 61 | "tool_title_query_sql": "使用SQL查询", 62 | "tool_title_get_current_time": "获取当前时间", 63 | "tool_title_generate_answer_with_doc": "结合文档生成答案(RAG)", 64 | "tool_title_get_block_kramdown": "获取块Kramdown", 65 | "tool_title_insert_block": "插入块", 66 | "tool_title_prepend_block": "前置插入块", 67 | "tool_title_append_block": "追加块", 68 | "tool_title_update_block": "更新块", 69 | "prompt_sql": "SQL查询与检索系统提示词", 70 | "prompt_flashcards": "创建闪卡系统提示词", 71 | "shortcut_history": "history", 72 | 73 | "history_title": "新增/修改类调用历史记录", 74 | "history_btn_pending": "只看待审", 75 | "history_btn_all": "显示全部", 76 | "history_sort": "排序", 77 | "history_sort_desc": "最新优先", 78 | "history_sort_asc": "最早优先", 79 | "history_col_id": "任务ID", 80 | "history_col_type": "类型", 81 | "history_col_objid": "新增/修改对象ID", 82 | "history_col_status": "状态", 83 | "history_col_created": "创建时间", 84 | "history_col_action": "操作", 85 | "history_col_content": "修改内容", 86 | "history_btn_view": "查看", 87 | "history_btn_approve": "批准", 88 | "history_btn_reject": "拒绝", 89 | "history_more": "更多", 90 | "history_dialog_allids": "全部修改内容ID", 91 | "history_dialog_diff": "差异对比", 92 | "history_dialog_close": "关闭", 93 | "history_dialog_fullcontent": "完整内容", 94 | "history_status_pending": "待审阅", 95 | "history_status_approved": "已批准", 96 | "history_status_rejected": "已拒绝", 97 | "history_status_unknown": "未知", 98 | "history_btn_clean": "清理历史记录", 99 | "history_btn_approve_all": "批准全部", 100 | "history_btn_reject_all": "拒绝全部", 101 | "tab_title_history": "MCP修改操作记录", 102 | "lang": "CN", 103 | "history_msg_reject_all_success": "已成功拒绝所有待审任务。", 104 | "history_msg_reject_all_error": "拒绝所有任务时发生错误,请重试。", 105 | "history_msg_approve_all_success": "已成功批准所有待审任务。", 106 | "history_msg_approve_all_error": "批准所有任务时发生错误,请重试。", 107 | "history_msg_clean_prompt": "请输入要清理的天数:", 108 | "history_msg_clean_title": "清理历史记录", 109 | "history_msg_clean_confirm": "确认清理", 110 | "history_msg_clean_cancel": "取消", 111 | "history_msg_clean_invalid": "请输入有效的天数。", 112 | "history_msg_clean_success": "历史记录清理成功。", 113 | "history_msg_clean_error": "清理历史记录时发生错误,请重试。", 114 | "history_msg_approve_success": "任务已批准。", 115 | "history_msg_reject_success": "任务已拒绝。", 116 | "history_msg_action_error": "处理任务时发生错误,请重试。", 117 | "message_id_not_exist": "要打开的块不存在,可能已被删除", 118 | "dialog_panel_plugin_name": "plugin: anMCPServer" 119 | } 120 | -------------------------------------------------------------------------------- /scripts/reset_dev_loc.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import http from 'node:http'; 3 | import readline from 'node:readline'; 4 | 5 | 6 | //************************************ Write you dir here ************************************ 7 | 8 | //Please write the "workspace/data/plugins" directory here 9 | //请在这里填写你的 "workspace/data/plugins" 目录 10 | let targetDir = ''; 11 | //Like this 12 | // let targetDir = `H:\\SiYuanDevSpace\\data\\plugins`; 13 | //******************************************************************************************** 14 | 15 | const log = (info) => console.log(`\x1B[36m%s\x1B[0m`, info); 16 | const error = (info) => console.log(`\x1B[31m%s\x1B[0m`, info); 17 | 18 | 19 | const SIYUAN_API_TOKEN = process.env.SIYUAN_API_TOKEN; 20 | 21 | log("read token " + SIYUAN_API_TOKEN) 22 | let POST_HEADER = { 23 | "Content-Type": "application/json", 24 | } 25 | if (SIYUAN_API_TOKEN) { 26 | POST_HEADER["Authorization"] = `Token ${SIYUAN_API_TOKEN}` 27 | } 28 | 29 | async function myfetch(url, options) { 30 | //使用 http 模块,从而兼容那些不支持 fetch 的 nodejs 版本 31 | return new Promise((resolve, reject) => { 32 | let req = http.request(url, options, (res) => { 33 | let data = ''; 34 | res.on('data', (chunk) => { 35 | data += chunk; 36 | }); 37 | res.on('end', () => { 38 | resolve({ 39 | ok: true, 40 | status: res.statusCode, 41 | json: () => JSON.parse(data) 42 | }); 43 | }); 44 | }); 45 | req.on('error', (e) => { 46 | reject(e); 47 | }); 48 | req.end(); 49 | }); 50 | } 51 | 52 | async function getSiYuanDir() { 53 | let url = 'http://127.0.0.1:6806/api/system/getWorkspaces'; 54 | let conf = {}; 55 | try { 56 | let response = await myfetch(url, { 57 | method: 'POST', 58 | headers: POST_HEADER 59 | }); 60 | if (response.ok) { 61 | conf = await response.json(); 62 | } else { 63 | error(`HTTP-Error: ${response.status}`); 64 | return null; 65 | } 66 | } catch (e) { 67 | error("Error:", e); 68 | error("Please make sure SiYuan is running!!!"); 69 | return null; 70 | } 71 | if (conf && conf.code != 0) { 72 | error("ERROR: " + conf.msg); 73 | } 74 | return conf.data; 75 | } 76 | 77 | async function chooseTarget(workspaces) { 78 | let count = workspaces.length; 79 | log(`Got ${count} SiYuan ${count > 1 ? 'workspaces' : 'workspace'}`) 80 | for (let i = 0; i < workspaces.length; i++) { 81 | log(`[${i}] ${workspaces[i].path}`); 82 | } 83 | 84 | if (count == 1) { 85 | return `${workspaces[0].path}/data/plugins`; 86 | } else { 87 | const rl = readline.createInterface({ 88 | input: process.stdin, 89 | output: process.stdout 90 | }); 91 | let index = await new Promise((resolve, reject) => { 92 | rl.question(`Please select a workspace[0-${count-1}]: `, (answer) => { 93 | resolve(answer); 94 | }); 95 | }); 96 | rl.close(); 97 | return `${workspaces[index].path}/data/plugins`; 98 | } 99 | } 100 | 101 | if (targetDir === '') { 102 | log('"targetDir" is empty, try to get SiYuan directory automatically....') 103 | let res = await getSiYuanDir(); 104 | 105 | if (res === null) { 106 | log('Failed! You can set the plugin directory in scripts/make_dev_link.js and try again'); 107 | process.exit(1); 108 | } 109 | 110 | targetDir = await chooseTarget(res); 111 | log(`Got target directory: ${targetDir}`); 112 | } 113 | 114 | //Check 115 | if (!fs.existsSync(targetDir)) { 116 | error(`Failed! plugin directory not exists: "${targetDir}"`); 117 | error(`Please set the plugin directory in scripts/make_dev_link.js`); 118 | process.exit(1); 119 | } 120 | 121 | 122 | //check if plugin.json exists 123 | if (!fs.existsSync('./plugin.json')) { 124 | //change dir to parent 125 | process.chdir('../'); 126 | if (!fs.existsSync('./plugin.json')) { 127 | error('Failed! plugin.json not found'); 128 | process.exit(1); 129 | } 130 | } 131 | 132 | //load plugin.json 133 | const plugin = JSON.parse(fs.readFileSync('./plugin.json', 'utf8')); 134 | const name = plugin?.name; 135 | if (!name || name === '') { 136 | error('Failed! Please set plugin name in plugin.json'); 137 | process.exit(1); 138 | } 139 | 140 | //dev directory 141 | const devDir = `${process.cwd()}/dev`; 142 | //mkdir if not exists 143 | if (!fs.existsSync(devDir)) { 144 | fs.mkdirSync(devDir); 145 | } 146 | 147 | function cmpPath(path1, path2) { 148 | path1 = path1.replace(/\\/g, '/'); 149 | path2 = path2.replace(/\\/g, '/'); 150 | // sepertor at tail 151 | if (path1[path1.length - 1] !== '/') { 152 | path1 += '/'; 153 | } 154 | if (path2[path2.length - 1] !== '/') { 155 | path2 += '/'; 156 | } 157 | return path1 === path2; 158 | } 159 | 160 | const targetPath = `${targetDir}/${name}`; 161 | //如果已经存在,就退出 162 | if (fs.existsSync(targetPath)) { 163 | let isSymbol = fs.lstatSync(targetPath).isSymbolicLink(); 164 | 165 | if (isSymbol) { 166 | let srcPath = fs.readlinkSync(targetPath); 167 | 168 | if (cmpPath(srcPath, devDir)) { 169 | log(`Good! ${targetPath} is already linked to ${devDir}`); 170 | } else { 171 | error(`Error! Already exists symbolic link ${targetPath}\nBut it links to ${srcPath}`); 172 | } 173 | } else { 174 | error(`Failed! ${targetPath} already exists and is not a symbolic link`); 175 | } 176 | 177 | } else { 178 | //创建软链接 179 | // fs.symlinkSync(devDir, targetPath, 'junction'); 180 | let devInfo = { 181 | "devDir": targetPath 182 | } 183 | fs.writeFileSync(`${process.cwd()}\\scripts\\devInfo.json`, JSON.stringify(devInfo), 'utf-8'); 184 | log(`Done! Changed dev save path ${targetPath}`); 185 | } 186 | 187 | -------------------------------------------------------------------------------- /src/tools/docRead.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createErrorResponse, createJsonResponse, createSuccessResponse } from "../utils/mcpResponse"; 3 | import { exportMdContent, getFileAPIv2, getKramdown } from "@/syapi"; 4 | import { McpToolsProvider } from "./baseToolProvider"; 5 | import { getBlockAssets, getBlockDBItem } from "@/syapi/custom"; 6 | import { blobToBase64Object } from "@/utils/common"; 7 | import { debugPush, errorPush, logPush } from "@/logger"; 8 | import { isValidStr } from "@/utils/commonCheck"; 9 | import { lang } from "@/utils/lang"; 10 | import { filterBlock } from "@/utils/filterCheck"; 11 | 12 | export class DocReadToolProvider extends McpToolsProvider { 13 | async getTools(): Promise[]> { 14 | return [{ 15 | name: "siyuan_read_doc_content_markdown", 16 | description: 'Retrieve the content of a document or block by its ID', 17 | schema: { 18 | id: z.string().describe("The unique identifier of the document or block"), 19 | offset: z.number().default(0).describe("The starting character offset for partial content reading (for pagination/large docs)"), 20 | limit: z.number().default(10000).describe("The maximum number of characters to return in this request"), 21 | }, 22 | handler: blockReadHandler, 23 | title: lang("tool_title_read_doc_content_markdown"), 24 | annotations: { 25 | readOnlyHint: true, 26 | } 27 | }, 28 | { 29 | name: "siyuan_get_block_kramdown", 30 | description: '从思源笔记中根据文档或块 ID 获取其完整的 Kramdown 内容。与普通文本不同,此 Kramdown 格式将保留包括颜色、属性、ID 在内的所有丰富格式信息。此工具主要用于修改前读取块内容,确保更新后能完整地保留原有格式。', 31 | schema: { 32 | id: z.string().describe("The unique identifier of the block"), 33 | }, 34 | handler: kramdownReadHandler, 35 | title: lang("tool_title_get_block_kramdown"), 36 | annotations: { 37 | readOnlyHint: true, 38 | } 39 | }]; 40 | } 41 | } 42 | 43 | async function blockReadHandler(params, extra) { 44 | const { id, offset = 0, limit = 10000 } = params; 45 | debugPush("读取文档内容"); 46 | // 检查输入 47 | const dbItem = await getBlockDBItem(id); 48 | if (dbItem == null) { 49 | return createErrorResponse("Invalid document or block ID. Please check if the ID exists and is correct."); 50 | } 51 | if (await filterBlock(id, dbItem)) { 52 | return createErrorResponse("The specified document or block is excluded by the user settings. So cannot write or read. "); 53 | } 54 | let otherImg = []; 55 | if (dbItem.type != "d") { 56 | try { 57 | otherImg = await getAssets(id); 58 | } catch (error) { 59 | errorPush("转换Assets为图片时出错", error); 60 | } 61 | } 62 | const markdown = await exportMdContent({id, refMode: 4, embedMode: 1, yfm: false}); 63 | // 返回块内容时,不应当返回文档标题,需要判断设置项 64 | if (dbItem.type != "d" && isValidStr(markdown["content"]) && window.siyuan.config.export.addTitle) { 65 | markdown["content"] = markdown["content"].replace(/^#{1,6}\s+.*\n?/, ''); 66 | } 67 | const content = markdown["content"] || ""; 68 | const sliced = content.slice(offset, offset + limit); 69 | const hasMore = offset + limit < content.length; 70 | return createJsonResponse({ 71 | content: sliced, 72 | offset, 73 | limit, 74 | "hasMore": hasMore, 75 | "totalLength": content.length 76 | }, otherImg); 77 | } 78 | 79 | async function kramdownReadHandler(params, extra) { 80 | const { id } = params; 81 | // 检查输入 82 | const dbItem = await getBlockDBItem(id); 83 | if (dbItem == null) { 84 | return createErrorResponse("Invalid block ID. Please check if the ID exists and is correct."); 85 | } 86 | if (await filterBlock(id, dbItem)) { 87 | return createErrorResponse("The specified document or block is excluded by the user settings. So cannot write or read. "); 88 | } 89 | let otherImg = []; 90 | if (dbItem.type != "d") { 91 | try { 92 | otherImg = await getAssets(id); 93 | } catch (error) { 94 | errorPush("转换Assets为图片时出错", error); 95 | } 96 | } 97 | const kramdown = await getKramdown(id); 98 | const content = kramdown || ""; 99 | return createJsonResponse({ 100 | kramdown: content, 101 | }, otherImg); 102 | } 103 | 104 | async function getAssets(id:string) { 105 | const assetsInfo = await getBlockAssets(id); 106 | const assetsPathList = assetsInfo.map(item=>item.path); 107 | const assetsPromise = []; 108 | assetsPathList.forEach((pathItem)=>{ 109 | if (isSupportedImageOrAudio(pathItem)) { 110 | assetsPromise.push(getFileAPIv2("/data/" + pathItem)); 111 | } 112 | }); 113 | const assetsBlobResult = await Promise.all(assetsPromise); 114 | const base64ObjPromise = []; 115 | let mediaLengthSum = 0; 116 | for (let blob of assetsBlobResult) { 117 | logPush("type", typeof blob, blob); 118 | if (blob.size / 1024 / 1024 > 2) { 119 | logPush("文件过大,暂不予返回", blob.size); 120 | } else if (mediaLengthSum / 1024 / 1024 > 5) { 121 | logPush("累计返回媒体过大,不再返回后续内容", mediaLengthSum); 122 | break; 123 | } else { 124 | mediaLengthSum += blob.size; 125 | base64ObjPromise.push(blobToBase64Object(blob)); 126 | } 127 | } 128 | return await Promise.all(base64ObjPromise); 129 | } 130 | 131 | function isSupportedImageOrAudio(path) { 132 | const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp', 'ico']; 133 | const audioExtensions = ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac']; 134 | 135 | const extMatch = path.match(/\.([a-zA-Z0-9]+)$/); 136 | if (!extMatch) return false; 137 | 138 | const ext = extMatch[1].toLowerCase(); 139 | 140 | if (imageExtensions.includes(ext)) { 141 | return 'image'; 142 | } else if (audioExtensions.includes(ext)) { 143 | return 'audio'; 144 | } else { 145 | return false; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/indexer/index.ts: -------------------------------------------------------------------------------- 1 | import { debugPush } from "@/logger"; 2 | import { getJSONFile, putJSONFile, removeFileAPI } from "@/syapi"; 3 | 4 | // 这是一个简化的锁实现,用于防止异步函数重入。 5 | // 在单线程的JS环境中,它通过一个Promise队列来确保操作按顺序执行。 6 | class AsyncLock { 7 | private promise = Promise.resolve(); 8 | 9 | public async acquire(fn: () => Promise): Promise { 10 | return new Promise((resolve, reject) => { 11 | this.promise = this.promise 12 | .then(() => fn().then(resolve, reject)) 13 | .catch(() => {}); // 捕获之前的错误,不影响新任务 14 | }); 15 | } 16 | } 17 | 18 | export class CacheQueue { 19 | private readonly cacheDir: string; 20 | private readonly readFilePath: string; 21 | private readonly writeFilePath: string; 22 | 23 | private readCache: T[] = []; 24 | private isRotating = false; 25 | 26 | // 使用锁来确保对文件的读写和轮换操作是原子性的 27 | private readonly readLock = new AsyncLock(); 28 | private readonly writeLock = new AsyncLock(); 29 | 30 | constructor(cacheDir: string) { 31 | this.cacheDir = cacheDir; 32 | // 为简单起见,我们使用 .json 而不是 .jsonl,因为 syapi 的函数似乎是为完整的JSON数组设计的 33 | this.readFilePath = `${this.cacheDir}/cache_a.json`; 34 | this.writeFilePath = `${this.cacheDir}/cache_b.json`; 35 | } 36 | 37 | /** 38 | * 初始化缓存队列,确保缓存文件存在。 39 | * 应该在使用队列前调用一次。 40 | */ 41 | public async init(): Promise { 42 | const readData = await getJSONFile(this.readFilePath); 43 | if (readData == null) { 44 | await putJSONFile(this.readFilePath, [], false); 45 | } 46 | const writeData = await getJSONFile(this.writeFilePath); 47 | if (writeData == null) { 48 | await putJSONFile(this.writeFilePath, [], false); 49 | } 50 | } 51 | 52 | /** 53 | * 将一个新项目添加到写队列(文件b)中。 54 | * 此操作是线程安全的。 55 | * @param item 要添加的项目。 56 | */ 57 | public async addToQueue(item: T): Promise { 58 | debugPush("item", item); 59 | return this.writeLock.acquire(async () => { 60 | let queue: T[] = []; 61 | try { 62 | // 读取文件b的现有内容 63 | queue = await getJSONFile(this.writeFilePath); 64 | if (!Array.isArray(queue)) { 65 | queue = []; // 如果文件内容不是数组,则重置 66 | } 67 | } catch (error) { 68 | // 文件可能为空或不存在,这没关系 69 | queue = []; 70 | } 71 | // 添加新项目并写回 72 | queue.push(item); 73 | await putJSONFile(this.writeFilePath, queue, false); 74 | }); 75 | } 76 | 77 | /** 78 | * 将一个新项目添加到写队列(文件b)中。 79 | * 此操作是线程安全的。 80 | * @param item 要添加的项目。 81 | */ 82 | public async batchAddToQueue(items: T[]): Promise { 83 | return this.writeLock.acquire(async () => { 84 | let queue: T[] = []; 85 | try { 86 | // 读取文件b的现有内容 87 | queue = await getJSONFile(this.writeFilePath); 88 | if (!Array.isArray(queue)) { 89 | queue = []; // 如果文件内容不是数组,则重置 90 | } 91 | } catch (error) { 92 | // 文件可能为空或不存在,这没关系 93 | queue = []; 94 | } 95 | // 添加新项目并写回 96 | queue.push(...items); 97 | await putJSONFile(this.writeFilePath, queue, false); 98 | }); 99 | } 100 | 101 | /** 102 | * 从读队列(文件a)中消费指定数量的项目。 103 | * 如果文件a消费完毕,它会自动轮换文件b为新的文件a。 104 | * 此操作是线程安全的。 105 | * @param count 希望消费的项目的数量。 106 | * @returns 一个包含已消费项目的数组。 107 | */ 108 | public async consume(count: number): Promise { 109 | return this.readLock.acquire(async () => { 110 | // 如果内存缓存为空,则从文件a加载 111 | if (this.readCache.length === 0) { 112 | await this.preloadCacheFromFile(); 113 | } 114 | 115 | // 如果缓存仍然为空,说明文件a是空的,尝试进行文件轮换 116 | if (this.readCache.length === 0) { 117 | await this.rotateFiles(); 118 | // 轮换后再次尝试从新的文件a加载 119 | await this.preloadCacheFromFile(); 120 | } 121 | 122 | // 如果在所有尝试之后缓存仍然为空,则说明整个队列都是空的 123 | if (this.readCache.length === 0) { 124 | return []; 125 | } 126 | 127 | // 从内存缓存中取出所需数量的项目 128 | const consumedCount = Math.min(count, this.readCache.length); 129 | const consumedItems = this.readCache.splice(0, consumedCount); 130 | 131 | return consumedItems; 132 | }); 133 | } 134 | 135 | /** 136 | * 内部方法:从读文件(文件a)加载内容到内存缓存。 137 | * 加载后,文件a将被清空。 138 | */ 139 | private async preloadCacheFromFile(): Promise { 140 | try { 141 | const data = await getJSONFile(this.readFilePath); 142 | if (Array.isArray(data) && data.length > 0) { 143 | this.readCache = data; 144 | // 清空文件a,因为内容已在内存中 145 | await putJSONFile(this.readFilePath, [], false); 146 | } 147 | } catch (e) { 148 | // 文件为空或不存在,缓存保持为空 149 | this.readCache = []; 150 | } 151 | } 152 | 153 | /** 154 | * 轮换文件:将文件b的内容移动到文件a,然后清空文件b。 155 | * 这是一个原子操作。 156 | */ 157 | private async rotateFiles(): Promise { 158 | // 防止重入 159 | if (this.isRotating) return; 160 | this.isRotating = true; 161 | 162 | try { 163 | // 使用写锁来确保在轮换期间没有新的写入操作干扰 164 | await this.writeLock.acquire(async () => { 165 | let itemsToMove: T[] = []; 166 | try { 167 | itemsToMove = await getJSONFile(this.writeFilePath); 168 | } catch (e) { 169 | // 文件b为空或不存在,无需移动 170 | } 171 | 172 | if (!Array.isArray(itemsToMove) || itemsToMove.length === 0) { 173 | // 如果文件b没有内容,则无需轮换 174 | return; 175 | } 176 | 177 | // 将文件b的内容写入文件a 178 | await putJSONFile(this.readFilePath, itemsToMove, false); 179 | 180 | // 清空文件b 181 | await removeFileAPI(this.writeFilePath).catch(() => {}); // 忽略删除错误 182 | await putJSONFile(this.writeFilePath, [], false); // 创建一个新的空文件b 183 | }); 184 | } finally { 185 | this.isRotating = false; 186 | } 187 | } 188 | } -------------------------------------------------------------------------------- /static/database_schema.md: -------------------------------------------------------------------------------- 1 | 你可以使用SQL对思源笔记的数据库进行查询,下面是一些查询提示。 2 | 3 | ## `blocks`表 4 | 5 | 该数据表存储了所有的内容块数据。 6 | 7 | ### 字段说明 8 | 9 | * `id`: 内容块 ID,格式为 `时间-7位随机字符`,例如 `20210104091228-d0rzbmm`。 10 | * `parent_id`: 双亲块 ID,格式同 `id` 11 | * `root_id`: 所在文档块 ID,格式同 `id` 12 | * `box`: 所在笔记本 ID,格式同 `id` 13 | * `path`: 内容块所在文档路径,例如 `/20200812220555-lj3enxa/20210808180320-abz7w6k/20200825162036-4dx365o.sy` 14 | * `hpath`: 人类可读的内容块所在文档路径,例如 `/0 请从这里开始/编辑器/排版元素` 15 | * `name`: 内容块名称 16 | * `alias`: 内容块别名 17 | * `memo`: 内容块备注 18 | * `tag`: 标签,例如 `#标签1 #标签2# #标签3#` 19 | * `content`: 去除了 Markdown 标记符的文本,在`type`=`d`时,该字段提供文档标题 20 | * `fcontent`: 存储容器块第一个子块的内容 21 | * `markdown`: 包含完整 Markdown 标记符的文本 22 | * `length`: `markdown` 字段文本长度 23 | * `type`: 内容块类型,详见下面的块主类型 24 | * `subtype`: 特定类型的内容块还存在子类型,详见下方的块次类型 25 | * `ial`: 内联属性列表,形如 `{: name="value"}`,例如 `{: id="20210104091228-d0rzbmm" updated="20210604222535"}` 26 | * `sort`: 排序权重,数值越小排序越靠前 27 | * `created`: 创建时间,格式为 `yyyyMMddHHmmss`,例如 `20210104091228` 28 | * `updated`: 更新时间,格式同 `created` 29 | 30 | ### `blocks.type` 31 | 32 | 块主类型 33 | - `audio`:音频块 34 | - `av`:属性表(数据库在内容块中的名称) 35 | - `b`:引述块 36 | - `c`:代码块 37 | - `d`:文档块 38 | - `h`:标题块 39 | - `html`:HTML块 40 | - `i`:列表项 41 | - `iframe`:iframe块 42 | - `l`:列表块 43 | - `m`:公式块 44 | - `p`:段落块 45 | - `query_embed`:嵌入块 46 | - `s`:超级块 47 | - `t`:表格块 48 | - `tb`:分割线 49 | - `video`:视频块 50 | - `widget`:挂件块 51 | 52 | ### `blocks.subtype` 53 | 54 | 块次类型,默认为空字符串 55 | 56 | - `h1`:一级标题块(关联 `h` 类型) 57 | - `h2`:二级标题块(关联 `h` 类型) 58 | - `h3`:三级标题块(关联 `h` 类型) 59 | - `h4`:四级标题块(关联 `h` 类型) 60 | - `h5`:五级标题块(关联 `h` 类型) 61 | - `h6`:六级标题块(关联 `h` 类型) 62 | - `o`:有序列表块(关联 `l` 类型) 63 | - `u`:无序列表块(关联 `l` 类型) 64 | - `t`:任务列表块(关联 `l` 类型) 65 | 66 | ## `refs`表 67 | 68 | 该表格中记录了内容块之间的引用关系。 69 | 70 | * `id`: 引用 ID,格式为 `时间-随机字符`,例如 `20211127144458-idb32wk` 71 | * `def_block_id`: 被引用块的块 ID,格式同 `id` 72 | * `def_block_root_id`: 被引用块所在文档的 ID,格式同 `id` 73 | * `def_block_path`: 被引用块所在文档的路径,例如 `/20200812220555-lj3enxa/20210808180320-fqgskfj/20200905090211-2vixtlf.sy` 74 | * `block_id`: 引用所在内容块 ID,格式同 `id` 75 | * `root_id`: 引用所在文档块 ID,格式同 `id` 76 | * `box`: 引用所在笔记本 ID,格式同 `id` 77 | * `path`: 引用所在文档块路径,例如 `/20200812220555-lj3enxa/20210808180320-fqgskfj/20200905090211-2vixtlf.sy` 78 | * `content`: 引用锚文本 79 | 80 | ## `attributes`表 81 | 82 | 该表格中记录了内容块的属性信息。 83 | 84 | * `id`: 属性 ID,格式为 `时间-随机字符`,例如 `20211127144458-h7y55zu` 85 | * `name`: 属性名称 86 | 87 | * 注意:思源中的用户自定义属性必须加上 `custom-` 前缀 88 | * 例如 `name` 是块的内置属性,但 `custom-name` 就是用户的自定义属性了 89 | * `value`: 属性值 90 | * `type`: 类型,例如 `b` 91 | * `block_id`: 块 ID,格式同 `id` 92 | * `root_id`: 文档 ID,格式同 `id` 93 | * `box`: 笔记本 ID,格式同 `id` 94 | * `path`: 文档文件路径,例如 `/20200812220555-lj3enxa.sy`。 95 | 96 | ## 查询要点 97 | 98 | * 所有 SQL 查询语句如果没有明确指定 `limit`,则会被思源查询引擎默认设置 `limit 64` 99 | * 块属性格式相关 100 | 101 | * 块 ID 格式统一为 `时间-随机字符`, 例如 `20210104091228-d0rzbmm` 102 | * 块的时间属性,如 created updated 的格式为 `YYYYMMDDHHmmss` 例如 `20210104091228` 103 | * 块之间的关系 104 | 105 | * 层级关系:块大致可以分为 106 | 107 | * 内容块(叶子块):仅包含内容的块,例如段落 `p`,公式块 `m`,代码块 `c`,标题块 `h`,表格块 `t` 等 108 | 109 | * 内容块的 `content`和 `markdown` 字段为块的内容 110 | * 容器块:包含其他内容块或者容器块的块,例如 列表块 `l`,列表项块 `i`,引述块/引用块 `b`,超级块 `s` 111 | 112 | * 每个块的 `parent_id` 指向他直接上层的容器块 113 | * 容器块的 `content`和 `markdown` 字段为容器内所有块的内容 114 | * 文档块:包含同一文档中所有内容块和容器块的块,`d` 115 | 116 | * 每个块的 `root_id` 指向他所在的文档 117 | * 容器块的 `content` 字段为文档的标题 118 | * 引用关系:当一个块引用了另一个块的时候,会在 refs 表中建立联系 119 | 120 | * 如果有多个块引用了同一个块,那么对这个被引用的块而言,这些引用它的块构成了它的反向链接(反链) 121 | * 所有引用关系被存放在 ref 表当中;使用的时候将 blocks 表和 ref 表搭配进行查询 122 | * Daily Note:又称日记,每日笔记,是一种特殊的**文档块** 123 | 124 | * daily note 文档有特殊属性:`custom-dailynote-=`;被标识了这个属性的文档块(type='d'),会被视为是对应日期的 daily note 125 | * 例如 `custom-dailynote-20240101=20240101` 的文档,被视为 2024-01-01 这天的 daily note 文档 126 | * 请注意! daily note (日记)是一个文档块!如果要查询日记内部的内容,请使用 `root_id` 字段来关联日记文档和内部的块的关系 127 | * 书签:含有属性 `bookmark=<书签名>` 的块会被加入对应的书签 128 | * 涉及到时间查询的问题,如果需要查询笔记内容,请灵活应用上面的规则,并结合工具siyuan_query_sql查询,并把查询结果输出。如果查询不到,输出你使用的SQL,让用户检查SQL是否有问题。 129 | 130 | ## SQL 示例 131 | 132 | * 查询所有文档块 133 | 134 | ```sql 135 | select * from blocks where type='d' 136 | ``` 137 | * 查询所有二级标题块 138 | 139 | ```sql 140 | select * from blocks where subtype = 'h2' 141 | ``` 142 | * 查询某个文档的子文裆 143 | 144 | ```sql 145 | select * from blocks 146 | where path like '%/<当前文档id>/%' and type='d' 147 | ``` 148 | * 随机漫游某个文档内所有标题块 149 | 150 | ```sql 151 | SELECT * FROM blocks 152 | WHERE root_id LIKE '<文档 id>' AND type = 'h' 153 | ORDER BY random() LIMIT 1 154 | ``` 155 | * 查询含有关键词「唯物主义」的段落块 156 | 157 | ```sql 158 | select * from blocks 159 | where markdown like '%唯物主义%' and type ='p' 160 | ORDER BY updated desc 161 | ``` 162 | * 查询过去 7 天内没有完成的任务(任务列表项) 163 | 164 | > 注:思源中,任务列表项的 markdown 为 `* [ ] Task text` 如果是已经完成的任务,则是 `* [x] Task Text` 165 | > 166 | 167 | ```sql 168 | SELECT * from blocks 169 | WHERE type = 'l' AND subtype = 't' 170 | AND created > strftime('%Y%m%d%H%M%S', datetime('now', '-7 day')) 171 | AND markdown like'* [ ] %' 172 | AND parent_id not in ( 173 | select id from blocks where subtype = 't' 174 | ) 175 | ``` 176 | * 查询过去7天内创建的日记 177 | ```sql 178 | select distinct B.* 179 | from blocks as B 180 | join attributes as A 181 | on B.id = A.block_id 182 | where A.name like 'custom-dailynote-%' 183 | and B.type = 'd' 184 | and A.value >= strftime('%Y%m%d', datetime('now', '-7 day')) 185 | and A.value <= strftime('%Y%m%d', 'now') 186 | order by A.value desc; 187 | ``` 188 | 189 | * 查询某个块所有的反链块(引用了这个块的所有块) 190 | 191 | ```sql 192 | select * from blocks where id in ( 193 | select block_id from refs where def_block_id = '<被引用的块ID>' 194 | ) limit 999 195 | ``` 196 | * 查询某个时间段内的 daily note(日记) 197 | 198 | > 注意由于没有指定 limit,最大只能查询 64 个 199 | > 200 | 201 | ```sql 202 | select distinct B.* from blocks as B join attributes as A 203 | on B.id = A.block_id 204 | where A.name like 'custom-dailynote-%' and B.type='d' 205 | and A.value >= '20231010' and A.value <= '20231013' 206 | order by A.value desc; 207 | ``` 208 | * 查询某个笔记本下没有被引用过的文档,限制 128 个 209 | 210 | ```sql 211 | select * from blocks as B 212 | where B.type='d' and box='<笔记本 BoxID>' and B.id not in ( 213 | select distinct R.def_block_id from refs as R 214 | ) order by updated desc limit 128 215 | ``` 216 | * 按文档分组查询结果,以查询“内容”为例; 217 | ```sql 218 | WITH document_id_temp AS ( SELECT root_id,Max(CASE WHEN type = 'd' THEN ( content || tag || name || alias || memo ) END) documentContent FROM blocks WHERE 1 = 1 AND type IN ( 'd' , 'h' , 'c' , 'm' , 't' , 'p' , 'html' , 'av' ) GROUP BY root_id HAVING 1 = 1 AND ( GROUP_CONCAT( ( content || tag || name || alias || memo ) ) LIKE '%内容%' ) ORDER BY ( (documentContent LIKE '%内容%') ) DESC , MAX(updated) DESC ) SELECT *, ( content || tag || name || alias || memo ) AS concatContent , (SELECT count( 1 ) FROM document_id_temp) as documentCount FROM blocks WHERE 1 = 1 AND type IN ( 'd' , 'h' , 'c' , 'm' , 't' , 'p' , 'html' , 'av' , 'd' ) AND ( id IN ( SELECT root_id FROM document_id_temp LIMIT 10 OFFSET 0 ) OR ( root_id IN ( SELECT root_id FROM document_id_temp LIMIT 10 OFFSET 0 ) AND ( concatContent LIKE '%内容%' ) ) ) ORDER BY sort ASC ,( (concatContent LIKE '%内容%') ) DESC , updated DESC LIMIT 2048; 219 | ``` -------------------------------------------------------------------------------- /src/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugin_name": "An MCP Service Plugin", 3 | "removeSpace": "Remove Space", 4 | "start_error": "MCP service failed to start. ", 5 | "port_error": "MCP service failed to start. ", 6 | "server_stop_error": "MCP service failed to stop. ", 7 | "server_running_on": "The MCP service runs on port ", 8 | "sse_warning": "You are connecting to the MCP service via a deprecated method (SSE). Please reconfigure by referring to README.md or the marketplace download page.", 9 | "code_warning": "Invalid access authorization code. Please set it according to the format requirements.", 10 | "code_encrypted": "Auth code has been set.", 11 | 12 | "setting_port": "Port", 13 | "setting_port_desp": "The port used by the MCP service. After modification, you need to save the settings before starting the service. Fill in the application with: http://127.0.0.1:[port_number]/mcp", 14 | "setting_autoStart": "Auto Start", 15 | "setting_autoStart_desp": "Control whether the MCP service starts with SiYuan", 16 | "setting_auth": "Client Access Control (Authentication Code / Token)", 17 | "setting_auth_desp": "The authorization code controls MCP service access, requiring 5+ characters (letters, numbers, or +-/._~). Set to N/A to disable authentication. Codes are hashed locally and become unreadable after saving. See Marketplace or README.md for details.", 18 | "setting_rag_baseurl": "The base url for plugin's RAG server", 19 | "setting_rag_baseurl_desp": "Experimental Feature. [This feature will be removed soon. See README.md for more info] For more infomation, please refer to RAG_BETA.md(zh-CN)", 20 | "setting_control": "Control", 21 | "setting_control_desp": "Start or stop the MCP service", 22 | "setting_readOnly": "\"Read-only\" mode", 23 | "setting_readOnly_desp": "If the current MCP service is already running, please save the settings first, then go to Settings and restart the MCP service for the changes to take effect. The developer may have missed some cases; this setting will only make a best-effort attempt to disable modification tools, but effectiveness is not guaranteed", 24 | "setting_autoApproveLocalChange": "Auto-approve in-place changes", 25 | "setting_autoApproveLocalChange_desp": "When enabled, in-place change operations (updateBlock) will be automatically approved without manual review.", 26 | "setting_filterDocId": "Exclude Documents", 27 | "setting_filterDocId_desp": "Enter one document ID per line. The plugin will stop reading and writing to these documents (and any of their sub-documents) based on this setting. Up to 100 entries are supported. [Note: Exclusion may not apply in certain tools or scenarios, such as SQL queries. For details, please refer to TOOL_INFO.md]", 28 | "setting_filterNotebookId": "Exclude Notebooks", 29 | "setting_filterNotebookId_desp": "Enter one notebook ID per line. The plugin will stop reading and writing to documents within the specified notebooks based on this setting. Up to 50 entries are supported. [Note: Exclusion may not apply in certain tools or scenarios, such as SQL queries. For details, please refer to TOOL_INFO.md]", 30 | "setting_control_start": "Start Service", 31 | "setting_control_stop": "Stop Service", 32 | "setting_status": "Status", 33 | "setting_status_connection": "Connections", 34 | "setting_status_open": "✅ Running", 35 | "setting_status_close": "❌ Stopped", 36 | "setting_status_refresh": "Refresh Status", 37 | "setting_status_desp": "Displays the current status of the MCP server, including connection count and running state.", 38 | "setting_copyright": "About", 39 | "setting_copyright_desp": "Translator: GPT 4o-mini & OpaqueGlass;
Developer: OpaqueGlass
Feedback & sponsor: github;
Released under AGPLv3 license;
Read 'README' file before using it.", 40 | "setting_readOnly_allow_all": "Allow all modifications", 41 | "setting_readOnly_allow_non_destructive": "Allow only non-destructive modifications", 42 | "setting_readOnly_deny_all": "Deny all modifications (read-only)", 43 | "tool_append_dailynote": "Append Markdown content to today's daily note in the specified notebook in SiYuan.\nThis tool is typically used to log information, thoughts, or summaries into the daily journal page automatically. \nOnly open notebooks (not in a closed state) are valid targets for appending.", 44 | "tool_get_current_time": "Get the current time, including year, month, day, hour, minute, second, day of the week, and other related information.", 45 | "tool_title_set_block_attributes": "Set Block Attributes", 46 | "tool_title_get_block_attributes": "Get Block Attributes", 47 | "tool_title_append_to_dailynote": "Append to Daily Note", 48 | "tool_title_list_notebook": "List Notebooks", 49 | "tool_title_read_dailynote": "Read Daily Note", 50 | "tool_title_read_doc_content_markdown": "Read Document Content (Markdown)", 51 | "tool_title_append_markdown_to_doc": "Append Markdown to Document", 52 | "tool_title_create_new_note_with_markdown_content": "Create New Note with Markdown", 53 | "tool_title_create_flashcards_with_new_doc": "Create Flashcards with New Document", 54 | "tool_title_create_flashcards": "Create Flashcards", 55 | "tool_title_delete_flashcards": "Delete Flashcards", 56 | "tool_title_get_doc_backlinks": "Get Document Backlinks", 57 | "tool_title_get_sub_doc_ids": "Get Sub-document IDs", 58 | "tool_title_search": "Search", 59 | "tool_title_query_syntax": "Get Query Syntax", 60 | "tool_title_database_schema": "Get Database Schema", 61 | "tool_title_query_sql": "Query with SQL", 62 | "tool_title_get_current_time": "Get Current Time", 63 | "tool_title_generate_answer_with_doc": "Generate Answer with Document (RAG)", 64 | "prompt_sql": "System prompt for sql query (zh-CN)", 65 | "prompt_flashcards": "System prompt for creating flashcards (zh-CN)", 66 | "tool_title_get_block_kramdown": "Get Block Kramdown", 67 | "tool_title_insert_block": "Insert Block", 68 | "tool_title_prepend_block": "Prepend Block", 69 | "tool_title_append_block": "Append Block", 70 | "tool_title_update_block": "Update Block", 71 | "shortcut_history": "history", 72 | "message_id_not_exist": "The corresponding block does not exist and may have been deleted.", 73 | 74 | "history_dialog_diff": "Difference", 75 | "history_title": "History of Add/Modify Tasks", 76 | "history_btn_pending": "Pending Only", 77 | "history_btn_all": "Show All", 78 | "history_sort": "Sort", 79 | "history_sort_desc": "Newest First", 80 | "history_sort_asc": "Oldest First", 81 | "history_col_id": "Task ID", 82 | "history_col_type": "Type", 83 | "history_col_objid": "Object ID (Add/Modify)", 84 | "history_col_status": "Status", 85 | "history_col_created": "Created At", 86 | "history_col_action": "Action", 87 | "history_col_content": "Content", 88 | "history_btn_view": "View", 89 | "history_btn_approve": "Approve", 90 | "history_btn_reject": "Reject", 91 | "history_more": "more", 92 | "history_dialog_allids": "All Modified IDs", 93 | "history_dialog_close": "Close", 94 | "history_dialog_fullcontent": "Full Content", 95 | 96 | "history_btn_clean": "Clean history", 97 | "history_btn_approve_all": "Approve All", 98 | "history_btn_reject_all": "Reject All", 99 | "history_msg_reject_all_success": "Successfully rejected all pending tasks.", 100 | "history_msg_reject_all_error": "An error occurred while rejecting all tasks. Please try again.", 101 | "history_msg_approve_all_success": "Successfully approved all pending tasks.", 102 | "history_msg_approve_all_error": "An error occurred while approving all tasks. Please try again.", 103 | "history_msg_clean_prompt": "Please enter the number of days to clean:", 104 | "history_msg_clean_title": "Clean History Records", 105 | "history_msg_clean_confirm": "Confirm Clean", 106 | "history_msg_clean_cancel": "Cancel", 107 | "history_msg_clean_invalid": "Please enter a valid number of days.", 108 | "history_msg_clean_success": "History records cleaned successfully.", 109 | "history_msg_clean_error": "An error occurred while cleaning history records. Please try again.", 110 | "history_msg_approve_success": "Task approved successfully.", 111 | "history_msg_reject_success": "Task rejected successfully.", 112 | "history_msg_action_error": "An error occurred while processing the task. Please try again.", 113 | 114 | "tab_title_history": "MCP Update Operation History", 115 | "dialog_panel_plugin_name": "plugin: anMCPServer", 116 | 117 | "lang": "EN" 118 | } -------------------------------------------------------------------------------- /src/tools/dailynote.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createErrorResponse, createJsonResponse, createSuccessResponse } from "../utils/mcpResponse"; 3 | import { appendBlockAPI, createDailyNote, getChildBlocks, getNotebookConf, queryAPI, removeBlockAPI, exportMdContent, getFileAPIv2 } from "@/syapi"; 4 | import { getPluginInstance } from "@/utils/pluginHelper"; 5 | import { isValidStr } from "@/utils/commonCheck"; 6 | import { lang } from "@/utils/lang"; 7 | import { McpToolsProvider } from "./baseToolProvider"; 8 | import { debugPush, logPush, warnPush, errorPush } from "@/logger"; 9 | import { getBlockAssets } from "@/syapi/custom"; 10 | import { blobToBase64Object } from "@/utils/common"; 11 | import { TASK_STATUS, taskManager } from "@/utils/historyTaskHelper"; 12 | import { filterNotebook } from "@/utils/filterCheck"; 13 | 14 | export class DailyNoteToolProvider extends McpToolsProvider { 15 | 16 | async getTools(): Promise[]> { 17 | return [{ 18 | name: "siyuan_append_to_dailynote", 19 | description: lang("tool_append_dailynote"), 20 | schema: { 21 | markdownContent: z.string().describe("The Markdown-formatted content to append to today's daily note."), 22 | notebookId: z.string().describe("The ID of the target notebook where the daily note is located. The notebook must not be in a closed state."), 23 | }, 24 | handler: appendToDailynoteHandler, 25 | title: lang("tool_title_append_to_dailynote"), 26 | annotations: { 27 | readOnlyHint: false, 28 | destructiveHint: false, 29 | idempotentHint: false, 30 | } 31 | },{ 32 | name: "siyuan_list_notebook", 33 | description: `List all notebooks in SiYuan and return their metadata(such as id, open status, dailyNoteSavePath etc.).`, 34 | schema: {}, 35 | handler: listNotebookHandler, 36 | title: lang("tool_title_list_notebook"), 37 | annotations: { 38 | readOnlyHint: true, 39 | } 40 | }, 41 | // { 42 | // name: "siyuan_read_dailynote", 43 | // description: "Read the content of a daily note for a specific date.", 44 | // schema: { 45 | // date: z.string().optional().describe("The date of the daily note in 'yyyyMMdd' format. If not provided, today's date will be used."), 46 | // notebookId: z.string().optional().describe("The ID of the notebook to search for the daily note. If not provided, a random notebook will be chosen."), 47 | // }, 48 | // handler: readDailynoteHandler, 49 | // title: lang("tool_title_read_dailynote"), 50 | // annotations: { 51 | // readOnlyHint: true, 52 | // } 53 | // } 54 | ] 55 | } 56 | } 57 | 58 | async function readDailynoteHandler(params, extra) { 59 | let { date, notebookId } = params; 60 | 61 | if (!date) { 62 | const now = new Date(); 63 | date = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}`; 64 | } 65 | 66 | // 检查 notebookId 和 date 的合法性 67 | if (notebookId && !/^[a-zA-Z0-9\-]+$/.test(notebookId)) { 68 | return createErrorResponse("Invalid notebookId format."); 69 | } 70 | if (!/^\d{8}$/.test(date)) { 71 | return createErrorResponse("Invalid date format. Expected 'yyyyMMdd'."); 72 | } 73 | 74 | if (filterNotebook(notebookId)) { 75 | return createErrorResponse("The specified notebook is excluded by the user settings."); 76 | } 77 | 78 | const boxCondition = notebookId ? `AND B.box = '${notebookId}'` : ""; 79 | 80 | // 首先尝试通过 custom attribute 查询 81 | let sql = `SELECT B.id FROM blocks AS B JOIN attributes AS A ON B.id = A.block_id WHERE A.name = 'custom-dailynote-${date}' ${boxCondition} AND B.type = 'd' LIMIT 1`; 82 | let queryResult = await queryAPI(sql); 83 | 84 | // 如果找不到,则回退到通过文档标题查询 85 | if (!queryResult || queryResult.length === 0) { 86 | const formattedDate = `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)}`; 87 | const boxCondition2 = notebookId ? `AND box = '${notebookId}'` : ""; 88 | sql = `SELECT id FROM blocks WHERE content = '${formattedDate}' ${boxCondition2} AND type = 'd' LIMIT 1`; 89 | queryResult = await queryAPI(sql); 90 | } 91 | 92 | if (!queryResult || queryResult.length === 0) { 93 | const notebookInfo = notebookId ? ` in notebook ${notebookId}` : ''; 94 | return createErrorResponse(`Daily note for date ${date} not found${notebookInfo}.`); 95 | } 96 | 97 | const docId = queryResult[0].id; 98 | 99 | let otherImg = []; 100 | try { 101 | otherImg = await getAssets(docId); 102 | } catch (error) { 103 | errorPush("Error converting assets to images", error); 104 | } 105 | 106 | const markdown = await exportMdContent({ id: docId, refMode: 4, embedMode: 1, yfm: false }); 107 | const content = markdown["content"] || ""; 108 | 109 | return createJsonResponse({ 110 | content: content, 111 | docId: docId, 112 | }, otherImg); 113 | } 114 | 115 | async function getAssets(id:string) { 116 | const assetsInfo = await getBlockAssets(id); 117 | const assetsPathList = assetsInfo.map(item=>item.path); 118 | const assetsPromise = []; 119 | assetsPathList.forEach((pathItem)=>{ 120 | if (isSupportedImageOrAudio(pathItem)) { 121 | assetsPromise.push(getFileAPIv2("/data/" + pathItem)); 122 | } 123 | }); 124 | const assetsBlobResult = await Promise.all(assetsPromise); 125 | const base64ObjPromise = []; 126 | let mediaLengthSum = 0; 127 | for (let blob of assetsBlobResult) { 128 | logPush("type", typeof blob, blob); 129 | if (blob.size / 1024 / 1024 > 2) { 130 | logPush("File too large, not returning", blob.size); 131 | } else if (mediaLengthSum / 1024 / 1024 > 5) { 132 | logPush("Total media size too large, not returning more content", mediaLengthSum); 133 | break; 134 | } else { 135 | mediaLengthSum += blob.size; 136 | base64ObjPromise.push(blobToBase64Object(blob)); 137 | } 138 | } 139 | return await Promise.all(base64ObjPromise); 140 | } 141 | 142 | function isSupportedImageOrAudio(path) { 143 | const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp', 'ico']; 144 | const audioExtensions = ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac']; 145 | 146 | const extMatch = path.match(/\.([a-zA-Z0-9]+)$/); 147 | if (!extMatch) return false; 148 | 149 | const ext = extMatch[1].toLowerCase(); 150 | 151 | if (imageExtensions.includes(ext)) { 152 | return 'image'; 153 | } else if (audioExtensions.includes(ext)) { 154 | return 'audio'; 155 | } else { 156 | return false; 157 | } 158 | } 159 | 160 | async function appendToDailynoteHandler(params, extra) { 161 | const {notebookId, markdownContent} = params; 162 | debugPush("插入日记API被调用", params); 163 | // notebook 164 | if (filterNotebook(notebookId)) { 165 | return createErrorResponse("The specified notebook is excluded by the user settings."); 166 | } 167 | // 确认dailynote id 168 | const id = await createDailyNote(notebookId, getPluginInstance().app.appId); 169 | // 追加写入 170 | let newBlockId = ""; 171 | if (isValidStr(id)) { 172 | // query先执行,否则可能真更新数据库了 173 | const queryResult = await queryAPI(`SELECT * FROM blocks WHERE id = "${id}"`); 174 | const result = await appendBlockAPI(markdownContent, id); 175 | if (result == null) { 176 | return createErrorResponse("Failed to append to dailynote"); 177 | } 178 | // 判断块个数,移除存在的唯一块 179 | if (queryResult && queryResult.length == 0) { 180 | try { 181 | const childList = await getChildBlocks(id); 182 | debugPush("貌似是新建日记,检查子块情况", childList); 183 | if (childList && childList.length >= 1 && childList[0].type == "p" && !isValidStr(childList[0]["markdown"])) { 184 | debugPush("移除子块", childList[0]); 185 | removeBlockAPI(childList[0].id); 186 | } 187 | } catch(err) { 188 | warnPush("err", err); 189 | } 190 | } 191 | newBlockId = result.id; 192 | } else { 193 | return createErrorResponse("Internal Error: failed to create dailynote"); 194 | } 195 | taskManager.insert(id, markdownContent, "appendToDailyNote", {}, TASK_STATUS.APPROVED); 196 | return createSuccessResponse("Successfully created the dailynote, the block ID for the new content is " + newBlockId); 197 | } 198 | 199 | async function listNotebookHandler(params, extra) { 200 | const notebooks = window?.siyuan?.notebooks; 201 | if (!notebooks) { 202 | return createJsonResponse([]); 203 | } 204 | 205 | const augmentedNotebooks = await Promise.all(notebooks.map(async (notebook) => { 206 | try { 207 | const confData = await getNotebookConf(notebook.id); 208 | if (confData && confData.conf) { 209 | return { 210 | ...notebook, 211 | refCreateSaveBox: confData.conf.refCreateSaveBox, 212 | refCreateSavePath: confData.conf.refCreateSavePath, 213 | docCreateSaveBox: confData.conf.docCreateSaveBox, 214 | docCreateSavePath: confData.conf.docCreateSavePath, 215 | dailyNoteSavePath: confData.conf.dailyNoteSavePath, 216 | dailyNoteTemplatePath: confData.conf.dailyNoteTemplatePath, 217 | }; 218 | } 219 | } catch (error) { 220 | warnPush(`Failed to get conf for notebook ${notebook.id}`, error); 221 | } 222 | return notebook; 223 | })); 224 | 225 | return createJsonResponse(augmentedNotebooks); 226 | } -------------------------------------------------------------------------------- /src/tools/blockWrite.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { createErrorResponse, createJsonResponse, createSuccessResponse } from "../utils/mcpResponse"; 3 | import { appendBlockAPI, insertBlockOriginAPI, prependBlockAPI, updateBlockAPI } from "@/syapi"; 4 | import { checkIdValid, getBlockDBItem } from "@/syapi/custom"; 5 | import { McpToolsProvider } from "./baseToolProvider"; 6 | import { debugPush } from "@/logger"; 7 | 8 | import { lang } from "@/utils/lang"; 9 | import { isCurrentVersionLessThan, isNonContainerBlockType, isValidNotebookId, isValidStr } from "@/utils/commonCheck"; 10 | import { TASK_STATUS, taskManager } from "@/utils/historyTaskHelper"; 11 | import { getPluginInstance } from "@/utils/pluginHelper"; 12 | import { extractNodeParagraphIds } from "@/utils/common"; 13 | import { filterBlock } from "@/utils/filterCheck"; 14 | 15 | export class BlockWriteToolProvider extends McpToolsProvider { 16 | async getTools(): Promise[]> { 17 | return [{ 18 | name: "siyuan_insert_block", 19 | description: "在指定位置插入一个新块。插入内容必须是 markdown 格式。插入位置可通过 `nextID` (后一个块ID)、`previousID` (前一个块ID) 或 `parentID` (父块ID) 之一来锚定。`nextID` 的优先级最高。", 20 | schema: { 21 | data: z.string().describe("待插入的 markdown 格式的块内容"), 22 | nextID: z.string().optional().describe("后一个块的ID,用于指定插入位置"), 23 | previousID: z.string().optional().describe("前一个块的ID,用于指定插入位置"), 24 | parentID: z.string().optional().describe("父块的ID,用于指定插入位置,父块必须是容器块,例如引述块、文档块等,但不包含标题块") 25 | }, 26 | handler: insertBlockHandler, 27 | title: lang("tool_title_insert_block"), 28 | annotations: { 29 | readOnlyHint: false, 30 | destructiveHint: false, 31 | idempotentHint: false 32 | } 33 | }, { 34 | name: "siyuan_prepend_block", 35 | description: "在指定父块的子块列表最前面插入一个新块,内容为 markdown 格式。", 36 | schema: { 37 | data: z.string().describe("待插入的 markdown 格式的块内容"), 38 | parentID: z.string().describe("父块的ID,父块必须是容器块,例如引述块、文档块等") 39 | }, 40 | handler: prependBlockHandler, 41 | title: lang("tool_title_prepend_block"), 42 | annotations: { 43 | readOnlyHint: false, 44 | destructiveHint: false, 45 | idempotentHint: false 46 | } 47 | }, { 48 | name: "siyuan_append_block", 49 | description: "在指定父块的子块列表最后面插入一个新块,内容为 markdown 格式。", 50 | schema: { 51 | data: z.string().describe("待插入的 markdown 格式的块内容"), 52 | parentID: z.string().describe("父块的ID,父块必须是容器块,例如引述块、文档块等") 53 | }, 54 | handler: appendBlockHandler, 55 | title: lang("tool_title_append_block"), 56 | annotations: { 57 | readOnlyHint: false, 58 | destructiveHint: false, 59 | idempotentHint: false 60 | } 61 | },{ 62 | name: "siyuan_update_block", 63 | description: "根据块ID更新现有块的内容,内容应当是 Kramdown 格式。使用markdown格式将丢失块的属性等信息。", 64 | schema: { 65 | data: z.string().describe("用于更新块的新内容,为 Kramdown 格式"), 66 | id: z.string().describe("待更新块的ID") 67 | }, 68 | handler: updateBlockHandler, 69 | title: lang("tool_title_update_block"), 70 | annotations: { 71 | readOnlyHint: false, 72 | destructiveHint: true, 73 | idempotentHint: false 74 | } 75 | }]; 76 | } 77 | } 78 | 79 | async function insertBlockHandler(params, extra) { 80 | const { data, nextID, previousID, parentID } = params; 81 | debugPush("插入内容块API被调用"); 82 | if (isValidNotebookId(nextID) || isValidNotebookId(previousID) || isValidNotebookId(parentID)) { 83 | return createErrorResponse("nextID, previousID, and parentID must be block IDs, not notebook IDs."); 84 | } 85 | // 选择优先级:nextID > previousID > parentID,取第一个有效的进行校验 86 | let anchorID: string | undefined; 87 | let anchorType: "nextID" | "previousID" | "parentID" | undefined; 88 | if (isValidStr(nextID)) { 89 | anchorID = nextID; 90 | anchorType = "nextID"; 91 | } else if (isValidStr(previousID)) { 92 | anchorID = previousID; 93 | anchorType = "previousID"; 94 | } else if (isValidStr(parentID)) { 95 | anchorID = parentID; 96 | anchorType = "parentID"; 97 | } 98 | 99 | if (!anchorID) { 100 | return createErrorResponse("Please provide one of nextID, previousID or parentID to anchor the insertion."); 101 | } 102 | 103 | // 校验格式并确认块存在 104 | checkIdValid(anchorID); 105 | const dbItem = await getBlockDBItem(anchorID); 106 | if (dbItem == null) { 107 | return createErrorResponse(`Invalid ${anchorType}: The specified block does not exist.`); 108 | } 109 | if (await filterBlock(anchorID, dbItem)) { 110 | return createErrorResponse("The specified block is excluded by the user settings. Can't read or write."); 111 | } 112 | 113 | // 仅当选中的锚点是 parentID 时,校验其是否为容器块(仅在旧版本需要此限制) 114 | if (anchorType === "parentID" && isNonContainerBlockType(dbItem.type) && isCurrentVersionLessThan("3.3.3")) { 115 | return createErrorResponse("Invalid parentID: Cannot insert a block under a non-container block."); 116 | } 117 | const response = await insertBlockOriginAPI({data, dataType: "markdown", nextID, previousID, parentID}); 118 | if (response == null) { 119 | return createErrorResponse("Failed to insert the block"); 120 | } 121 | taskManager.insert(response[0].doOperations[0].id, data, "insertBlock", { parentID }, TASK_STATUS.APPROVED); 122 | return createJsonResponse(response[0].doOperations[0]); 123 | } 124 | 125 | async function prependBlockHandler(params, extra) { 126 | const { data, parentID } = params; 127 | debugPush("前置内容块API被调用"); 128 | // 检查块存在 129 | checkIdValid(parentID); 130 | if (isValidNotebookId(parentID)) { 131 | return createErrorResponse("parentID must be a block ID, not a notebook ID."); 132 | } 133 | const dbItem = await getBlockDBItem(parentID); 134 | if (dbItem == null) { 135 | return createErrorResponse("Invalid parentID: The specified parent block does not exist."); 136 | } 137 | if (await filterBlock(parentID, dbItem)) { 138 | return createErrorResponse("The specified block is excluded by the user settings. Can't read or write."); 139 | } 140 | if (isNonContainerBlockType(dbItem.type) && isCurrentVersionLessThan("3.3.3")) { 141 | return createErrorResponse("Invalid parentID: Cannot insert a block under a non-container block."); 142 | } 143 | // 执行 144 | const response = await prependBlockAPI(data, parentID); 145 | if (response == null) { 146 | return createErrorResponse("Failed to prepend the block"); 147 | } 148 | taskManager.insert(response.id, data, "prependBlock", { parentID }, TASK_STATUS.APPROVED); 149 | return createJsonResponse(response); 150 | } 151 | 152 | async function appendBlockHandler(params, extra) { 153 | const { data, parentID } = params; 154 | debugPush("追加内容块API被调用"); 155 | // 需要确认:1) 块存在 2) 块是文档块、不是notebook、不是paragraph 156 | checkIdValid(parentID); 157 | if (isValidNotebookId(parentID)) { 158 | return createErrorResponse("parentID must be a block ID, not a notebook ID."); 159 | } 160 | const dbItem = await getBlockDBItem(parentID); 161 | if (dbItem == null) { 162 | return createErrorResponse("Invalid parentID: The specified parent block does not exist."); 163 | } 164 | if (await filterBlock(parentID, dbItem)) { 165 | return createErrorResponse("The specified block is excluded by the user settings. Can't read or write."); 166 | } 167 | if (isNonContainerBlockType(dbItem.type) && isCurrentVersionLessThan("3.3.3")) { 168 | return createErrorResponse("Invalid parentID: Cannot insert a block under a non-container block."); 169 | } 170 | //执行 171 | const result = await appendBlockAPI(data, parentID); 172 | if (result == null) { 173 | return createErrorResponse("Failed to append to the block"); 174 | } 175 | // 对于在列表后append列表,会导致返回的id是不存在的,还是需要解析dom,这里只提取段落块 176 | const paragraphIds = []; 177 | if (dbItem.type === "l") { 178 | const listItems = extractNodeParagraphIds(result.data); 179 | if (listItems.length > 0) { 180 | paragraphIds.push(...listItems); 181 | } else { 182 | paragraphIds.push(result.id); 183 | } 184 | } else { 185 | paragraphIds.push(result.id); 186 | } 187 | taskManager.insert(paragraphIds, data, "appendBlock", { parentID }, TASK_STATUS.APPROVED); 188 | return createJsonResponse(result); 189 | } 190 | 191 | 192 | async function updateBlockHandler(params, extra) { 193 | const { data, id } = params; 194 | // 检查块存在 195 | checkIdValid(id); 196 | const blockDbItem = await getBlockDBItem(id); 197 | if (blockDbItem == null) { 198 | return createErrorResponse("Invalid block ID. Please check if the ID exists and is correct."); 199 | } 200 | if (await filterBlock(id, blockDbItem)) { 201 | return createErrorResponse("The specified block is excluded by the user settings. Can't read or write."); 202 | } 203 | if (blockDbItem.type === "av") { 204 | return createErrorResponse("Cannot update attribute view (i.e. Database) blocks."); 205 | } 206 | // 执行 207 | const plugin = getPluginInstance(); 208 | const autoApproveLocalChange = plugin?.mySettings["autoApproveLocalChange"]; 209 | if (autoApproveLocalChange) { 210 | const response = await updateBlockAPI(data, id); 211 | if (response == null) { 212 | return createErrorResponse("Failed to update the block"); 213 | } 214 | taskManager.insert(id, data, "updateBlock", {}, TASK_STATUS.APPROVED); 215 | return createSuccessResponse("Block updated successfully."); 216 | } else { 217 | taskManager.insert(id, data, "updateBlock", {}, TASK_STATUS.PENDING); 218 | return createSuccessResponse("Changes have entered the waiting queue, please remind users to review "); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/tools/flashCard.ts: -------------------------------------------------------------------------------- 1 | import { addRiffCards, getRiffDecks, queryAPI, removeRiffCards } from "@/syapi"; 2 | import { getBlockDBItem, isValidDeck, QUICK_DECK_ID } from "@/syapi/custom"; 3 | import { isValidStr } from "@/utils/commonCheck"; 4 | import { createErrorResponse, createJsonResponse, createSuccessResponse } from "@/utils/mcpResponse"; 5 | import { createNewDocWithParentId } from "./sharedFunction"; 6 | import { McpToolsProvider } from "./baseToolProvider"; 7 | import { z } from "zod"; 8 | import { useWsIndexQueue } from "@/utils/wsMainHelper"; 9 | import { TASK_STATUS, taskManager } from "@/utils/historyTaskHelper"; 10 | import { filterBlock } from "@/utils/filterCheck"; 11 | 12 | const TYPE_VALID_LIST = ["h1", "h2", "h3", "h4", "h5", "highlight", "superBlock"] as const; 13 | 14 | export class FlashcardToolProvider extends McpToolsProvider { 15 | async getTools(): Promise[]> { 16 | return [{ 17 | name: "siyuan_create_flashcards_with_new_doc", 18 | description: "Create New Document, and Make Flashcards with Specific Method", 19 | schema: { 20 | parentId: z.string().describe("The ID of the parent document where the new document will be created."), 21 | docTitle: z.string().describe("The title of the new document that will contain the flashcards."), 22 | type: z.enum(TYPE_VALID_LIST).describe("The block type to use when formatting flashcards (e.g., heading or highlight)."), 23 | deckId: z.string().optional().describe("The ID of the flashcard deck to which the new content belongs."), 24 | markdownContent: z.string().describe("The Markdown-formatted content to append at the end of the new document."), 25 | }, 26 | handler: addFlashCardMarkdown, 27 | title: "Create Flashcards with New Doc", 28 | annotations: { 29 | readOnlyHint: false, 30 | destructiveHint: false, 31 | idempotentHint: false, 32 | } 33 | }, 34 | { 35 | name: "siyuan_create_flashcards", 36 | description: "Create flashcards from one or more block IDs.", 37 | schema: { 38 | blockIds: z.array(z.string()).describe("The IDs of the blocks to be converted into flashcards."), 39 | deckId: z.string().optional().describe("The ID of the deck to add the cards to. If not provided, a default deck will be used."), 40 | }, 41 | handler: createFlashcardsHandler, 42 | title: "Create Flashcards", 43 | annotations: { 44 | readOnlyHint: false, 45 | destructiveHint: false, 46 | idempotentHint: false, 47 | } 48 | }, 49 | { 50 | name: "siyuan_delete_flashcards", 51 | description: "Delete flashcards from a deck using their corresponding block IDs.", 52 | schema: { 53 | blockIds: z.array(z.string()).describe("The IDs of the blocks corresponding to the flashcards to be deleted."), 54 | deckId: z.string().optional().describe("The ID of the deck to remove the cards from. If not provided, a default deck will be used."), 55 | }, 56 | handler: deleteFlashcardsHandler, 57 | title: "Delete Flashcards", 58 | annotations: { 59 | readOnlyHint: false, 60 | destructiveHint: true, 61 | idempotentHint: false, 62 | } 63 | }]; 64 | } 65 | } 66 | async function addFlashCardMarkdown(params, extra) { 67 | let { parentId, docTitle, type, deckId, markdownContent } = params; 68 | let { sendNotification, _meta} = extra; 69 | 70 | if (await filterBlock(parentId, null)) { 71 | return createErrorResponse("The specified document or block is excluded by the user settings, so cannot create a new note under it."); 72 | } 73 | // 默认deck 74 | if (!isValidStr(deckId)) { 75 | deckId = QUICK_DECK_ID; 76 | } 77 | if (!await isValidDeck(deckId)) { 78 | return createErrorResponse("制卡失败:卡包DeckId不存在,如果用户没有明确指定卡包名称或ID,可以将参数deckId设置为\"\""); 79 | } 80 | if (type === "highlight" && !window.siyuan.config.editor.markdown.inlineMath) { 81 | return createErrorResponse("制卡失败:高亮内容制卡需要用户启用Markdown标记语法,请提醒用户开启此功能(设置-编辑器-Markdown行级标记语法)"); 82 | } 83 | const {result, newDocId} = await createNewDocWithParentId(parentId, docTitle, markdownContent); 84 | if (result) { 85 | taskManager.insert(newDocId, markdownContent, "createNewNoteWithFlashCard", {}, TASK_STATUS.APPROVED); 86 | } 87 | if (result) { 88 | let progressInterval: any; 89 | if (_meta?.progressToken) { 90 | let currentProgress = 0; 91 | const maxDuration = 120 * 1000; // 2 minutes in ms 92 | const updateInterval = 200; // ms 93 | const progressIncrement = updateInterval / maxDuration; 94 | 95 | progressInterval = setInterval(() => { 96 | currentProgress += progressIncrement; 97 | if (currentProgress < 0.95) { 98 | sendNotification({ 99 | method: "notifications/progress", 100 | params: { progress: currentProgress, progressToken: _meta.progressToken } 101 | }); 102 | } 103 | }, updateInterval); 104 | } 105 | 106 | try { 107 | // 需要等待索引完成,自 https://github.com/siyuan-note/siyuan/issues/15390 更新后,此等待索引方式实际无效,变为sleep 108 | const addCardsResult = await useWsIndexQueue()?.enqueue(async ()=>{ 109 | return await parseDocAddCards(newDocId, type, deckId); 110 | }); 111 | 112 | if (_meta?.progressToken) { 113 | await sendNotification({ 114 | method: "notifications/progress", 115 | params: { progress: 1, progressToken: _meta.progressToken } 116 | }); 117 | } 118 | return createSuccessResponse(`成功添加了 ${addCardsResult} 张闪卡`); 119 | } finally { 120 | if (progressInterval) { 121 | clearInterval(progressInterval); 122 | } 123 | } 124 | } else { 125 | return createErrorResponse("制卡失败:创建闪卡文档时遇到未知问题"); 126 | } 127 | } 128 | 129 | async function createFlashcardsHandler(params, extra) { 130 | let { blockIds, deckId } = params; 131 | let { sendNotification, _meta} = extra; 132 | 133 | if (!isValidStr(deckId)) { 134 | deckId = QUICK_DECK_ID; 135 | } 136 | if (!await isValidDeck(deckId)) { 137 | return createErrorResponse("Card creation failed: The DeckId does not exist. If the user has not specified a deck name or ID, set the deckId parameter to an empty string."); 138 | } 139 | const filteredIds = []; 140 | for (let i = 0; i < blockIds.length; i++) { 141 | const blockId = blockIds[i]; 142 | const dbItem = await getBlockDBItem(blockId); 143 | if (dbItem == null) { 144 | return createErrorResponse(`Invalid block ID: ${blockId}. Please check if the ID exists and is correct.`); 145 | } 146 | if (await filterBlock(blockId, dbItem)) { 147 | continue; 148 | } 149 | filteredIds.push(blockId); 150 | if (_meta?.progressToken) { 151 | await sendNotification({ 152 | method: "notifications/progress", 153 | params: { progress: (i + 1) / blockIds.length + 2, progressToken: _meta.progressToken } 154 | }); 155 | } 156 | } 157 | 158 | const addCardsResult = await addRiffCards(filteredIds, deckId); 159 | if (addCardsResult === null) { 160 | return createErrorResponse("Failed to create flashcards."); 161 | } 162 | return createSuccessResponse(`Successfully added ${filteredIds.length} flashcards.`); 163 | } 164 | 165 | async function deleteFlashcardsHandler(params, extra) { 166 | let { blockIds, deckId } = params; 167 | 168 | if (!isValidStr(deckId)) { 169 | deckId = ""; 170 | } 171 | if (!await isValidDeck(deckId) && deckId !== "") { 172 | return createErrorResponse("Card deletion failed: The DeckId does not exist. If the user has not specified a deck name or ID, set the deckId parameter to an empty string."); 173 | } 174 | 175 | const removeResult = await removeRiffCards(blockIds, deckId); 176 | if (removeResult === null) { 177 | return createErrorResponse("Failed to delete flashcards."); 178 | } 179 | return createSuccessResponse(`Successfully removed flashcards corresponding to ${blockIds.length} blocks.`); 180 | } 181 | 182 | async function parseDocAddCards(docId:string, addType: string, deckId: string) { 183 | const functionDict = { 184 | "h1": provideHeadingIds.bind(this, docId, addType), 185 | "h2": provideHeadingIds.bind(this, docId, addType), 186 | "h3": provideHeadingIds.bind(this, docId, addType), 187 | "h4": provideHeadingIds.bind(this, docId, addType), 188 | "h5": provideHeadingIds.bind(this, docId, addType), 189 | "highlight": provideHighlightBlockIds.bind(this, docId), 190 | "superBlock": provideSuperBlockIds.bind(this, docId), 191 | } 192 | const blockIds = await functionDict[addType](); 193 | let afterAddSize = await addRiffCards(blockIds, deckId); 194 | return blockIds.length; 195 | } 196 | 197 | async function listDeck(params, extra) { 198 | if (!window.siyuan.config.flashcard.deck) { 199 | return createSuccessResponse("用户禁用了卡包,在调用其他工具时,可以直接将参数deckId设置为\"\""); 200 | } 201 | const deckResponse = await getRiffDecks(); 202 | return createJsonResponse(deckResponse); 203 | } 204 | 205 | // async function addFlashCardFromExistBlock(params, extra) { 206 | // const { blockId, deckId } = params; 207 | // // 确认入参 208 | 209 | // } 210 | 211 | function isValidType(type) { 212 | return TYPE_VALID_LIST.includes(type); 213 | } 214 | 215 | function getIdFromSqlItem(sqlResponse) { 216 | sqlResponse = sqlResponse ?? []; 217 | return sqlResponse.map(item=>item.id); 218 | } 219 | 220 | async function provideHeadingIds(docId: string, headingType: string) { 221 | let queryResult = await queryAPI(`select id from blocks where root_id = '${docId}' and type = 'h' and subtype = '${headingType}';`); 222 | return getIdFromSqlItem(queryResult); 223 | } 224 | async function provideSuperBlockIds(docId:string) { 225 | let queryResult = await queryAPI(`select * from blocks where root_id = '${docId}' and type = 's'`); 226 | return getIdFromSqlItem(queryResult); 227 | } 228 | async function provideHighlightBlockIds(docId:string) { 229 | let queryResult = await queryAPI(`SELECT * FROM blocks WHERE 230 | root_id = '${docId}' 231 | AND 232 | type = "p" 233 | AND 234 | markdown regexp '==.*=='`); 235 | let finalResult = new Array(); 236 | queryResult.forEach((oneResult) => { 237 | let oneContent = oneResult.markdown; 238 | // logPush(`[正则检查]原内容`, oneContent); 239 | oneContent = oneContent.replace(new RegExp("(?!<\\\\)`[^`]*`(?!`)", "g"), ""); 240 | // logPush(`[正则检查]移除行内代码`, oneContent); 241 | let regExp = new RegExp("(? 128) { 31 | // totalWords = `${totalWords}+`; 32 | // break; 33 | // } 34 | // } 35 | // return [childDocs, totalWords]; 36 | } 37 | 38 | export async function getChildDocuments(sqlResult:SqlResult, maxListCount: number): Promise { 39 | let childDocs = await listDocsByPathT({path: sqlResult.path, notebook: sqlResult.box, maxListCount: maxListCount}); 40 | return childDocs; 41 | } 42 | 43 | export async function getChildDocumentIds(sqlResult:SqlResult, maxListCount: number): Promise { 44 | let childDocs = await listDocsByPathT({path: sqlResult.path, notebook: sqlResult.box, maxListCount: maxListCount}); 45 | return childDocs.map(item=>item.id); 46 | } 47 | 48 | export async function isChildDocExist(id: string) { 49 | const sqlResponse = await queryAPI(` 50 | SELECT * FROM blocks WHERE path like '%${id}/%' LIMIT 3 51 | `); 52 | if (sqlResponse && sqlResponse.length > 0) { 53 | return true; 54 | } 55 | return false; 56 | } 57 | 58 | export async function isDocHasAv(docId: string) { 59 | let sqlResult = await queryAPI(` 60 | SELECT count(*) as avcount FROM blocks WHERE root_id = '${docId}' 61 | AND type = 'av' 62 | `); 63 | if (sqlResult.length > 0 && sqlResult[0].avcount > 0) { 64 | return true; 65 | } else { 66 | 67 | return false; 68 | } 69 | } 70 | 71 | export async function isDocEmpty(docId: string, blockCountThreshold = 0) { 72 | // 检查父文档是否为空 73 | let treeStat = await getTreeStat(docId); 74 | if (blockCountThreshold == 0 && treeStat.wordCount != 0 && treeStat.imageCount != 0) { 75 | debugPush("treeStat判定文档非空,不插入挂件"); 76 | return false; 77 | } 78 | if (blockCountThreshold != 0) { 79 | let blockCountSqlResult = await queryAPI(`SELECT count(*) as bcount FROM blocks WHERE root_id like '${docId}' AND type in ('p', 'c', 'iframe', 'html', 'video', 'audio', 'widget', 'query_embed', 't')`); 80 | if (blockCountSqlResult.length > 0) { 81 | if (blockCountSqlResult[0].bcount > blockCountThreshold) { 82 | return false; 83 | } else { 84 | return true; 85 | } 86 | } 87 | } 88 | 89 | let sqlResult = await queryAPI(`SELECT markdown FROM blocks WHERE 90 | root_id like '${docId}' 91 | AND type != 'd' 92 | AND (type != 'p' 93 | OR (type = 'p' AND length != 0) 94 | ) 95 | LIMIT 5`); 96 | if (sqlResult.length <= 0) { 97 | return true; 98 | } else { 99 | debugPush("sql判定文档非空,不插入挂件"); 100 | return false; 101 | } 102 | } 103 | 104 | export function getActiveDocProtyle() { 105 | const allProtyle = {}; 106 | window.siyuan.layout.centerLayout?.children?.forEach((wndItem) => { 107 | wndItem?.children?.forEach((tabItem) => { 108 | if (tabItem?.model) { 109 | allProtyle[tabItem?.id](tabItem.model?.editor?.protyle); 110 | } 111 | }); 112 | }); 113 | } 114 | 115 | export function getActiveEditorIds() { 116 | let result = []; 117 | let id = window.document.querySelector(`.layout__wnd--active [data-type="tab-header"].item--focus`)?.getAttribute("data-id"); 118 | if (id) return [id]; 119 | window.document.querySelectorAll(`[data-type="tab-header"].item--focus`).forEach(item=>{ 120 | let uid = item.getAttribute("data-id"); 121 | if (uid) result.push(uid); 122 | }); 123 | return result; 124 | } 125 | 126 | 127 | 128 | /** 129 | * 获取当前更新时间字符串 130 | * @returns 131 | */ 132 | export function getUpdateString(){ 133 | let nowDate = new Date(); 134 | let hours = nowDate.getHours(); 135 | let minutes = nowDate.getMinutes(); 136 | let seconds = nowDate.getSeconds(); 137 | hours = formatTime(hours); 138 | minutes = formatTime(minutes); 139 | seconds = formatTime(seconds); 140 | let timeStr = nowDate.toJSON().replace(new RegExp("-", "g"),"").substring(0, 8) + hours + minutes + seconds; 141 | return timeStr; 142 | function formatTime(num) { 143 | return num < 10 ? '0' + num : num; 144 | } 145 | } 146 | 147 | /** 148 | * 生成一个随机的块id 149 | * @returns 150 | */ 151 | export function generateBlockId(){ 152 | // @ts-ignore 153 | if (window?.Lute?.NewNodeID) { 154 | // @ts-ignore 155 | return window.Lute.NewNodeID(); 156 | } 157 | let timeStr = getUpdateString(); 158 | let alphabet = new Array(); 159 | for (let i = 48; i <= 57; i++) alphabet.push(String.fromCharCode(i)); 160 | for (let i = 97; i <= 122; i++) alphabet.push(String.fromCharCode(i)); 161 | let randomStr = ""; 162 | for (let i = 0; i < 7; i++){ 163 | randomStr += alphabet[Math.floor(Math.random() * alphabet.length)]; 164 | } 165 | let result = timeStr + "-" + randomStr; 166 | return result; 167 | } 168 | 169 | /** 170 | * 转换块属性对象为{: }格式IAL字符串 171 | * @param {*} attrData 其属性值应当为String类型 172 | * @returns 173 | */ 174 | export function transfromAttrToIAL(attrData) { 175 | let result = "{:"; 176 | for (let key in attrData) { 177 | result += ` ${key}=\"${attrData[key]}\"`; 178 | } 179 | result += "}"; 180 | if (result == "{:}") return null; 181 | return result; 182 | } 183 | 184 | 185 | export function removeCurrentTabF(docId?:string) { 186 | // 获取tabId 187 | if (!isValidStr(docId)) { 188 | docId = getCurrentDocIdF(true); 189 | } 190 | if (!isValidStr(docId)) { 191 | debugPush("错误的id或多个匹配id"); 192 | return; 193 | } 194 | // v3.1.11或以上 195 | if (siyuanAPIs?.getAllEditor) { 196 | const editor = siyuanAPIs.getAllEditor(); 197 | let protyle = null; 198 | for (let i = 0; i < editor.length; i++) { 199 | if (editor[i].protyle.block.rootID === docId) { 200 | protyle = editor[i].protyle; 201 | break; 202 | } 203 | } 204 | if (protyle) { 205 | if (protyle.model.headElement) { 206 | if (protyle.model.headElement.classList.contains("item--pin")) { 207 | debugPush("Pin页面,不关闭存在页签"); 208 | return; 209 | } 210 | } 211 | //id: string, closeAll = false, animate = true, isSaveLayout = true 212 | debugPush("关闭存在页签", protyle?.model?.parent?.parent, protyle.model?.parent?.id); 213 | protyle?.model?.parent?.parent?.removeTab(protyle.model?.parent?.id, false, false); 214 | } else { 215 | debugPush("没有找到对应的protyle,不关闭存在的页签"); 216 | return; 217 | } 218 | } else { // v3.1.10或以下 219 | return; 220 | } 221 | 222 | } 223 | 224 | export function isValidIdFormat(id: string): boolean { 225 | const idRegex = /^\d{14}-[a-zA-Z0-9]{7}$/gm; 226 | return idRegex.test(id); 227 | } 228 | 229 | export function checkIdValid(id: string): void { 230 | if (!isValidIdFormat(id)) { 231 | throw new Error("The `id` format is incorrect, please check if it is a valid `id`."); 232 | } 233 | } 234 | 235 | 236 | export async function isADocId(id:string): Promise { 237 | if (!isValidStr(id)) return false; 238 | if (!isValidIdFormat(id)) { 239 | return false; 240 | } 241 | const queryResponse = await queryAPI(`SELECT type FROM blocks WHERE id = '${id}'`); 242 | if (queryResponse == null || queryResponse.length == 0) { 243 | return false; 244 | } 245 | if (queryResponse[0].type == "d") { 246 | return true; 247 | } 248 | return false; 249 | } 250 | 251 | export async function getDocDBitem(id:string) { 252 | if (!isValidStr(id)) return null; 253 | checkIdValid(id); 254 | const safeId = id.replace(/'/g, "''"); 255 | const queryResponse = await queryAPI(`SELECT * FROM blocks WHERE id = '${safeId}' and type = 'd'`); 256 | if (queryResponse == null || queryResponse.length == 0) { 257 | return null; 258 | } 259 | return queryResponse[0]; 260 | } 261 | /** 262 | * 通过id获取数据库中的id 263 | * @param id 块id或文档id 264 | * @returns DB item 265 | */ 266 | export async function getBlockDBItem(id:string) { 267 | if (!isValidStr(id)) return null; 268 | checkIdValid(id); 269 | const safeId = id.replace(/'/g, "''"); 270 | const queryResponse = await queryAPI(`SELECT * FROM blocks WHERE id = '${safeId}'`); 271 | if (queryResponse == null || queryResponse.length == 0) { 272 | return null; 273 | } 274 | return queryResponse[0]; 275 | } 276 | 277 | export interface IAssetsDBItem { 278 | /** 引用 ID,资源自身的唯一标识 */ 279 | id: string; 280 | /** 所属块的 ID,表示该资源挂载在哪个块上 */ 281 | block_id: string; 282 | /** 所属文档的 ID */ 283 | root_id: string; 284 | /** 所属笔记本(Box)的 ID */ 285 | box: string; 286 | /** 所属文档的路径,比如 `/20200812220555-lj3enxa/20200915214115-42b8zma.sy` */ 287 | docpath: string; 288 | /** 资源文件的相对路径,比如 `assets/siyuan-128-20210604092205-djd749a.png` */ 289 | path: string; 290 | /** 资源文件名,比如 `siyuan-128-20210604092205-djd749a.png` */ 291 | name: string; 292 | /** 资源的标题,比如 `源于思考,饮水思源`,可以为空 */ 293 | title: string; 294 | /** 资源文件的 SHA256 哈希,用于校验或去重 */ 295 | hash: string; 296 | } 297 | 298 | 299 | /** 300 | * 获取附件信息 301 | * @param id 块id 302 | * @returns 数组列表 303 | */ 304 | export async function getBlockAssets(id:string): Promise { 305 | const queryResponse = await queryAPI(`SELECT * FROM assets WHERE block_id = '${id}'`); 306 | if (queryResponse == null || queryResponse.length == 0) { 307 | return []; 308 | } 309 | return queryResponse; 310 | } 311 | 312 | /** 313 | * 递归地获取所有下层级文档的id 314 | * @param id 文档id 315 | * @returns 所有下层级文档的id 316 | */ 317 | export async function getSubDocIds(id:string) { 318 | // 添加idx? 319 | const docInfo = await getDocDBitem(id); 320 | const treeList = await listDocTree(docInfo["box"], docInfo["path"].replace(".sy", "")); 321 | const subIdsSet = new Set(); 322 | function addToSet(obj) { 323 | if (obj instanceof Array) { 324 | obj.forEach(item=>addToSet(item)); 325 | return; 326 | } 327 | if (obj == null) { 328 | return; 329 | } 330 | if (isValidStr(obj["id"])) { 331 | subIdsSet.add(obj["id"]); 332 | } 333 | if (obj["children"] != undefined ) { 334 | for (let item of obj["children"]) { 335 | addToSet(item); 336 | } 337 | } 338 | } 339 | addToSet(treeList); 340 | logPush("subIdsSet", subIdsSet, treeList); 341 | return Array.from(subIdsSet); 342 | } 343 | 344 | export const QUICK_DECK_ID = "20230218211946-2kw8jgx"; 345 | 346 | export async function isValidDeck(deckId) { 347 | if (deckId === QUICK_DECK_ID) return true; 348 | const deckResponse = await getRiffDecks(); 349 | return !!deckResponse.find(item => item.id == deckId); 350 | } 351 | -------------------------------------------------------------------------------- /src/components/history.vue: -------------------------------------------------------------------------------- 1 | 115 | 116 | 341 | 342 | --------------------------------------------------------------------------------