diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index 7667fe3..0cc3945 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -1,4 +1,9 @@ releases: + - version: "0.6.0" + features: + - "Added Infio built-in MCP (Model Control Protocol) support" + - "Optimized startup performance for faster loading times" + - "Support Infio provider MCP tools" - version: "0.5.2" features: - "Added Infio provider" diff --git a/manifest.json b/manifest.json index af558fb..bd6f4ed 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "infio-copilot", "name": "Infio Copilot", - "version": "0.5.2", + "version": "0.6.0", "minAppVersion": "0.15.0", "description": "A Cursor-inspired AI assistant for notes that offers smart autocomplete and interactive chat with your selected notes", "author": "Felix.D", diff --git a/package.json b/package.json index e1df92c..bfee0ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-infio-copilot", - "version": "0.5.2", + "version": "0.6.0", "description": "A Cursor-inspired AI assistant that offers smart autocomplete and interactive chat with your selected notes", "main": "main.js", "scripts": { diff --git a/src/components/chat-view/McpHubView.tsx b/src/components/chat-view/McpHubView.tsx index 4ebbd60..203a4f8 100644 --- a/src/components/chat-view/McpHubView.tsx +++ b/src/components/chat-view/McpHubView.tsx @@ -128,11 +128,11 @@ const McpHubView = () => {
- {tool.name} + {tool.name.replace('COMPOSIO_SEARCH_', '')}
{tool.description && ( -

{tool.description}

+

{tool.description.replace('composio', '')}

)} {(tool.inputSchema && (() => { const schema = tool.inputSchema; @@ -285,7 +285,7 @@ const McpHubView = () => { {isExpanded ? : }
-

{server.name}

+

{server.name.replace('infio-builtin-server', 'builtin')}

e.stopPropagation()}> diff --git a/src/components/chat-view/QueryProgress.tsx b/src/components/chat-view/QueryProgress.tsx index b6d2777..ac4e4b4 100644 --- a/src/components/chat-view/QueryProgress.tsx +++ b/src/components/chat-view/QueryProgress.tsx @@ -87,7 +87,7 @@ export default function QueryProgress({ {t('chat.queryProgress.readingFilesDone')}

- {t('chat.queryProgress.filesLoaded', { count: state.fileContents.length })} + {t('chat.queryProgress.filesLoaded').replace('{count}', state.fileContents.length.toString())}

) @@ -115,7 +115,7 @@ export default function QueryProgress({ {t('chat.queryProgress.readingWebsitesDone')}

- {t('chat.queryProgress.websitesLoaded', { count: state.websiteContents.length })} + {t('chat.queryProgress.websitesLoaded').replace('{count}', state.websiteContents.length.toString())}

) diff --git a/src/database/json/constants.ts b/src/database/json/constants.ts index 02ab272..f9576bc 100644 --- a/src/database/json/constants.ts +++ b/src/database/json/constants.ts @@ -2,4 +2,5 @@ export const ROOT_DIR = '.infio_json_db' export const COMMAND_DIR = 'commands' export const CHAT_DIR = 'chats' export const CUSTOM_MODE_DIR = 'custom_modes' +export const CONVERT_DATA_DIR = 'convert_data' export const INITIAL_MIGRATION_MARKER = '.initial_migration_completed' diff --git a/src/database/json/convert-data/ConvertDataManager.ts b/src/database/json/convert-data/ConvertDataManager.ts new file mode 100644 index 0000000..8e1e226 --- /dev/null +++ b/src/database/json/convert-data/ConvertDataManager.ts @@ -0,0 +1,185 @@ +import { createHash } from 'crypto' + +import { App } from 'obsidian' +import { v4 as uuidv4 } from 'uuid' + + +import { AbstractJsonRepository } from '../base' +import { CONVERT_DATA_DIR, ROOT_DIR } from '../constants' + +import { + CONVERT_DATA_SCHEMA_VERSION, + ConvertData, + ConvertDataMetadata, + ConvertType +} from './types' + +export class ConvertDataManager extends AbstractJsonRepository< + ConvertData, + ConvertDataMetadata +> { + constructor(app: App) { + super(app, `${ROOT_DIR}/${CONVERT_DATA_DIR}`) + } + + protected parseFileName(fileName: string): ConvertDataMetadata | null { + // Check if filename is a valid MD5 hash (32 hex characters) + const match = fileName.match( + new RegExp(`^([a-f0-9]{32})\\.json$`), + ) + if (!match) return null + + return { + id: match[1], + md5Hash: match[1], + name: '', + type: '', + source: '', + createdAt: 0, + updatedAt: 0, + schemaVersion: 0, + } + } + + protected generateFileName(data: ConvertData): string { + // Format: {md5Hash}.json + return `${data.md5Hash}.json` + } + + /** + * 生成源的MD5哈希值 + */ + public static generateSourceHash(source: string): string { + return createHash('md5').update(source).digest('hex') + } + + /** + * 创建转换数据记录 + */ + public async createConvertData( + convertData: Omit< + ConvertData, + 'id' | 'md5Hash' | 'createdAt' | 'updatedAt' | 'schemaVersion' + >, + ): Promise { + const md5Hash = ConvertDataManager.generateSourceHash(convertData.source) + const now = Date.now() + + const newConvertData: ConvertData = { + id: uuidv4(), + md5Hash, + ...convertData, + createdAt: now, + updatedAt: now, + schemaVersion: CONVERT_DATA_SCHEMA_VERSION, + } + + await this.create(newConvertData) + return newConvertData + } + + /** + * 根据源查找转换数据 + */ + public async findBySource(source: string): Promise { + const md5Hash = ConvertDataManager.generateSourceHash(source) + return this.findByHash(md5Hash) + } + + /** + * 根据MD5哈希查找转换数据 + */ + public async findByHash(md5Hash: string): Promise { + const fileName = `${md5Hash}.json` + + try { + return await this.read(fileName) + } catch { + return null + } + } + + /** + * 根据ID查找转换数据 + */ + public async findById(id: string): Promise { + const allMetadata = await this.listMetadata() + const targetMetadata = allMetadata.find((meta) => meta?.id === id) + + if (!targetMetadata) return null + + const fileName = `${targetMetadata.md5Hash}.json` + + try { + return await this.read(fileName) + } catch { + return null + } + } + + /** + * 更新转换数据 + */ + public async updateConvertData( + id: string, + updates: Partial< + Omit + >, + ): Promise { + const convertData = await this.findById(id) + if (!convertData) return null + + const updatedConvertData: ConvertData = { + ...convertData, + ...updates, + updatedAt: Date.now(), + } + + await this.update(convertData, updatedConvertData) + return updatedConvertData + } + + /** + * 删除转换数据 + */ + public async deleteConvertData(id: string): Promise { + const convertData = await this.findById(id) + if (!convertData) return false + + const fileName = this.generateFileName(convertData) + await this.delete(fileName) + return true + } + + /** + * 列出所有转换数据 + */ + public async listConvertData(): Promise { + const allMetadata = await this.listMetadata() + const allConvertData = await Promise.all( + allMetadata.map(async (meta) => { + if (!meta) return null + + const fileName = `${meta.md5Hash}.json` + + try { + return await this.read(fileName) + } catch { + return null + } + }) + ) + + return allConvertData + .filter((data): data is ConvertData => data !== null) + .sort((a, b) => b.updatedAt - a.updatedAt) + } + + /** + * 根据类型列出转换数据 + */ + public async listByType(type: ConvertType): Promise { + const allData = await this.listConvertData() + return allData.filter((data) => data.type === type) + } +} diff --git a/src/database/json/convert-data/index.ts b/src/database/json/convert-data/index.ts new file mode 100644 index 0000000..1d5308b --- /dev/null +++ b/src/database/json/convert-data/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './ConvertDataManager' diff --git a/src/database/json/convert-data/types.ts b/src/database/json/convert-data/types.ts new file mode 100644 index 0000000..1cbd1fe --- /dev/null +++ b/src/database/json/convert-data/types.ts @@ -0,0 +1,33 @@ +import { z } from "zod" + +export const CONVERT_DATA_SCHEMA_VERSION = 1 + +export const convertTypeSchema = z.enum(['CONVERT_VIDEO', 'CONVERT_DOCUMENT']) + +export type ConvertType = z.infer + +export const convertDataSchema = z.object({ + id: z.string().uuid("Invalid ID"), + md5Hash: z.string().min(32, "MD5 hash must be 32 characters"), // 用于查询的md5 + name: z.string().min(1, "Name is required"), // url标题或文件名称 + type: convertTypeSchema, // CONVERT_VIDEO 或 CONVERT_DOCUMENT + source: z.string().min(1, "Source is required"), // 转换源(视频链接或文件路径) + contentPath: z.string().optional(), // 转换后存储的md文件路径,可能被移动 + content: z.string().min(1, "Content is required"), // 转换后的markdown文本 + createdAt: z.number().int().positive(), + updatedAt: z.number().int().positive(), + schemaVersion: z.literal(CONVERT_DATA_SCHEMA_VERSION), +}) + +export type ConvertData = z.infer + +export type ConvertDataMetadata = { + id: string + md5Hash: string + name: string + type: string + source: string + createdAt: number + updatedAt: number + schemaVersion: number +} diff --git a/src/lang/locale/zh-cn.ts b/src/lang/locale/zh-cn.ts index c2f8dfb..baf80f9 100644 --- a/src/lang/locale/zh-cn.ts +++ b/src/lang/locale/zh-cn.ts @@ -409,7 +409,7 @@ export default { noErrors: "没有错误记录", parameters: "参数", toolNoDescription: "无描述", - useMcpToolFrom: "Use MCP tool from", + useMcpToolFrom: "使用来自以下的 MCP 工具:", } } }; diff --git a/src/utils/prompt-generator.ts b/src/utils/prompt-generator.ts index 7194858..f91c1c3 100644 --- a/src/utils/prompt-generator.ts +++ b/src/utils/prompt-generator.ts @@ -6,6 +6,8 @@ import { DiffStrategy } from '../core/diff/DiffStrategy' import { McpHub } from '../core/mcp/McpHub' import { SystemPrompt } from '../core/prompts/system' import { RAGEngine } from '../core/rag/rag-engine' +import { ConvertDataManager } from '../database/json/convert-data/ConvertDataManager' +import { ConvertType } from '../database/json/convert-data/types' import { SelectVector } from '../database/schema' import { ChatMessage, ChatUserMessage } from '../types/chat' import { ContentPart, RequestMessage } from '../types/llm/request' @@ -141,6 +143,7 @@ export class PromptGenerator { private customModePrompts: CustomModePrompts | null = null private customModeList: ModeConfig[] | null = null private getMcpHub: () => Promise | null = null + private convertDataManager: ConvertDataManager private static readonly EMPTY_ASSISTANT_MESSAGE: RequestMessage = { role: 'assistant', content: '', @@ -163,6 +166,7 @@ export class PromptGenerator { this.customModePrompts = customModePrompts ?? null this.customModeList = customModeList ?? null this.getMcpHub = getMcpHub ?? null + this.convertDataManager = new ConvertDataManager(app) } public async generateRequestMessages({ @@ -388,14 +392,22 @@ export class PromptGenerator { completedFiles: completedFiles }) - const content = await getFileOrFolderContent( - file, - this.app.vault, - this.app - ) + // 如果文件不是 md 文件且 mcpHub 存在,使用 MCP 工具转换 + const mcpHub = await this.getMcpHub?.() + let content: string + let markdownFilePath = '' + if (file.extension !== 'md' && mcpHub?.isBuiltInServerAvailable()) { + [content, markdownFilePath] = await this.callMcpToolConvertDocument(file, mcpHub) + } else { + content = await getFileOrFolderContent( + file, + this.app.vault, + this.app + ) + } // 创建Markdown文件 - const markdownFilePath = await this.createMarkdownFileForContent( + markdownFilePath = markdownFilePath || await this.createMarkdownFileForContent( file.path, content, false @@ -522,13 +534,11 @@ export class PromptGenerator { completedUrls: completedUrls }) - const content = await this.getWebsiteContent(url, mcpHub) - + const [content, mcpContentPath] = await this.getWebsiteContent(url, mcpHub) // 从内容中提取标题 const websiteTitle = this.extractTitleFromWebsiteContent(content, url) - // 为网页内容创建Markdown文件 - const markdownFilePath = await this.createMarkdownFileForContent( + const contentPath = mcpContentPath || await this.createMarkdownFileForContent( url, content, true, @@ -536,8 +546,8 @@ export class PromptGenerator { ) completedUrls++ - urlContents.push({ url: markdownFilePath, content }) // 这里url改为markdownFilePath - allWebsiteReadResults.push({ url: markdownFilePath, content }) // 同样这里也改为markdownFilePath + urlContents.push({ url: contentPath, content }) + allWebsiteReadResults.push({ url: contentPath, content }) } // 网页读取完成 @@ -574,8 +584,11 @@ export class PromptGenerator { // 如果当前文件不是 md 文件且 mcpHub 存在,使用 MCP 工具转换 const mcpHub = await this.getMcpHub?.() + let currentMarkdownFilePath = '' if (currentFile.file.extension !== 'md' && mcpHub?.isBuiltInServerAvailable()) { - currentFileContent = await this.callMcpToolConvertDocument(currentFile.file, mcpHub) + const [mcpCurrFileContent, mcpCurrFileContentPath] = await this.callMcpToolConvertDocument(currentFile.file, mcpHub) + currentFileContent = mcpCurrFileContent + currentMarkdownFilePath = mcpCurrFileContentPath } else { currentFileContent = await getFileOrFolderContent( currentFile.file, @@ -585,7 +598,7 @@ export class PromptGenerator { } // 为当前文件创建Markdown文件 - const currentMarkdownFilePath = await this.createMarkdownFileForContent( + currentMarkdownFilePath = currentMarkdownFilePath || await this.createMarkdownFileForContent( currentFile.file.path, currentFileContent, false @@ -966,22 +979,18 @@ When writing out new markdown blocks, remember not to include "line_number|" at } - private async getPdfContent(file: TFile): Promise { - return await parsePdfContent(file, this.app) - } - - /** * TODO: Improve markdown conversion logic * - filter visually hidden elements * ... */ - private async getWebsiteContent(url: string, mcpHub: McpHub | null): Promise { + private async getWebsiteContent(url: string, mcpHub: McpHub | null): Promise<[string, string]> { const mcpHubAvailable = mcpHub?.isBuiltInServerAvailable() if (mcpHubAvailable && isVideoUrl(url)) { - return this.callMcpToolConvertVideo(url, mcpHub) + const [md, mdPath] = await this.callMcpToolConvertVideo(url, mcpHub) + return [md, mdPath] } if (isYoutubeUrl(url)) { @@ -989,17 +998,28 @@ When writing out new markdown blocks, remember not to include "line_number|" at const { title, transcript } = await YoutubeTranscript.fetchTranscriptAndMetadata(url) - return `Title: ${title} + return [ + `Title: ${title} Video Transcript: -${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}` +${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}`, + '' + ] } const response = await requestUrl({ url }) - return htmlToMarkdown(response.text) + return [htmlToMarkdown(response.text), ''] } - private async callMcpToolConvertVideo(url: string, mcpHub: McpHub): Promise { + private async callMcpToolConvertVideo(url: string, mcpHub: McpHub): Promise<[string, string]> { + // 首先检查缓存 + const cachedData = await this.convertDataManager.findBySource(url) + if (cachedData) { + console.log(`Using cached video conversion for: ${url}`) + return [cachedData.content, cachedData.contentPath] + } + + // 如果没有缓存,进行转换 const response = await mcpHub.callTool( 'infio-builtin-server', 'CONVERT_VIDEO', @@ -1007,23 +1027,41 @@ ${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}` ) // 处理图片内容并获取图片引用 - const imageReferences = await this.processImagesInResponse(response.content) + // @ts-ignore + await this.processImagesInResponse(response.content) const textContent = response.content.find((c) => c.type === 'text') - let result = textContent?.text || '' + // @ts-ignore + const md = textContent?.text as string || '' - // 在文本内容末尾添加图片引用 - if (imageReferences.length > 0) { - result += '\n\n## 图片内容\n\n' - imageReferences.forEach(imagePath => { - result += `![](${imagePath})\n\n` - }) - } + // 创建Markdown文件 + const websiteTitle = this.extractTitleFromWebsiteContent(md, url) - return result + // 为网页内容创建Markdown文件 + const mdPath = await this.createMarkdownFileForContent( + url, + md, + true, + websiteTitle, + ) + + // 异步保存到缓存(不等待,避免阻塞) + this.saveConvertDataToCache(url, 'CONVERT_VIDEO', md, mdPath, url).catch(error => { + console.error('Failed to save video conversion to cache:', error) + }) + + return [md, mdPath] } - private async callMcpToolConvertDocument(file: TFile, mcpHub: McpHub): Promise { + private async callMcpToolConvertDocument(file: TFile, mcpHub: McpHub): Promise<[string, string]> { + // 首先检查缓存 + const cachedData = await this.convertDataManager.findBySource(file.path) + if (cachedData) { + console.log(`Using cached document conversion for: ${file.path}`) + return [cachedData.content, cachedData.contentPath] + } + + // 如果没有缓存,进行转换 // 读取文件的二进制内容并转换为Base64 const fileBuffer = await this.app.vault.readBinary(file) @@ -1052,12 +1090,23 @@ ${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}` ) // 处理图片内容并获取图片引用 + // @ts-ignore await this.processImagesInResponse(response.content) - const textContent = response.content.find((c) => c.type === 'text') - const result = textContent?.text as string || '' + // @ts-ignore + const textContent = response.content.find((c: { type: string; text?: string }) => c.type === 'text') + // @ts-ignore + const md = textContent?.text as string || '' - return result + // 创建Markdown文件 + const mdPath = await this.createMarkdownFileForContent(file.path, md, false, file.name) + + // 异步保存到缓存 + this.saveConvertDataToCache(file.path, 'CONVERT_DOCUMENT', md, mdPath, file.name).catch(error => { + console.error('Failed to save document conversion to cache:', error) + }) + + return [md, mdPath] } /** @@ -1107,7 +1156,7 @@ ${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}` return file.path } catch (error) { console.error('Failed to create markdown file:', error) - return originalPath // 如果创建失败,返回原路径 + return "" } } @@ -1188,11 +1237,74 @@ ${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}` return savedImagePaths } + /** + * 根据 MIME 类型获取图片扩展名 + */ + private getImageExtensionFromMimeType(mimeType?: string): string { + if (!mimeType) return 'png' + + const extensionMap: Record = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + } + + return extensionMap[mimeType.toLowerCase()] || 'png' + } + + /** + * 保存转换数据到缓存 + */ + private async saveConvertDataToCache( + source: string, + type: ConvertType, + content: string, + contentPath: string, + name?: string + ): Promise { + try { + // 生成名称 + let displayName = name + if (!displayName) { + if (type === 'CONVERT_VIDEO') { + // 从URL提取名称 + try { + const url = new URL(source) + displayName = url.hostname + url.pathname + } catch { + displayName = source + } + } else { + // 从文件路径提取名称 + displayName = source.split('/').pop() || source + } + } + + // 保存到数据库 + await this.convertDataManager.createConvertData({ + name: displayName, + type, + source, + contentPath, + content, + }) + + console.log(`Saved conversion data to cache: ${source}`) + } catch (error) { + console.error('Failed to save conversion data to cache:', error) + throw error + } + } + /** * 将base64图片数据保存为文件到Obsidian资源目录 */ private async saveImageFromBase64(base64Data: string, filename: string, mimeType?: string): Promise { // 获取默认资源目录 + // @ts-ignore const staticResourceDir = this.app.vault.getConfig("attachmentFolderPath") // 构建完整的文件路径