diff --git a/src/components/chat-view/ChatView.tsx b/src/components/chat-view/ChatView.tsx index f6200c1..361f17a 100644 --- a/src/components/chat-view/ChatView.tsx +++ b/src/components/chat-view/ChatView.tsx @@ -64,6 +64,8 @@ import McpHubView from './McpHubView' // Moved after MarkdownReasoningBlock import QueryProgress, { QueryProgressState } from './QueryProgress' import ReactMarkdown from './ReactMarkdown' import SimilaritySearchResults from './SimilaritySearchResults' +import FileReadResults from './FileReadResults' +import WebsiteReadResults from './WebsiteReadResults' import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock' // Add an empty line here @@ -1079,6 +1081,18 @@ const Chat = forwardRef((props, ref) => { ) }} /> + {message.fileReadResults && ( + + )} + {message.websiteReadResults && ( + + )} {message.similaritySearchResults && ( { + openMarkdownFile(app, fileResult.path) + } + + const getFileSize = (content: string) => { + const bytes = new Blob([content]).size + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + } + + return ( +
+
+ +
+
+
+ {path.basename(fileResult.path)} +
+
+ {fileResult.path} +
+
+
+ {getFileSize(fileResult.content)} +
+
+ ) +} + +export default function FileReadResults({ + fileContents, +}: { + fileContents: Array<{ path: string, content: string }> +}) { + const [isOpen, setIsOpen] = useState(false) + + return ( +
+
{ + setIsOpen(!isOpen) + }} + className="infio-file-read-results__trigger" + > + {isOpen ? : } +
+ {t('chat.fileResults.showReadFiles')} ({fileContents.length}) +
+
+ {isOpen && ( +
+ {fileContents.map((fileResult, index) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/components/chat-view/QueryProgress.tsx b/src/components/chat-view/QueryProgress.tsx index 81bb598..b6d2777 100644 --- a/src/components/chat-view/QueryProgress.tsx +++ b/src/components/chat-view/QueryProgress.tsx @@ -4,6 +4,26 @@ export type QueryProgressState = | { type: 'reading-mentionables' } + | { + type: 'reading-files' + currentFile?: string + totalFiles?: number + completedFiles?: number + } + | { + type: 'reading-files-done' + fileContents: Array<{ path: string, content: string }> + } + | { + type: 'reading-websites' + currentUrl?: string + totalUrls?: number + completedUrls?: number + } + | { + type: 'reading-websites-done' + websiteContents: Array<{ url: string, content: string }> + } | { type: 'indexing' indexProgress: IndexProgress @@ -43,6 +63,62 @@ export default function QueryProgress({

) + case 'reading-files': + return ( +
+

+ {t('chat.queryProgress.readingFiles')} + +

+ {state.currentFile && ( +

+ {state.currentFile} + {state.totalFiles && state.completedFiles !== undefined && ( + ({state.completedFiles}/{state.totalFiles}) + )} +

+ )} +
+ ) + case 'reading-files-done': + return ( +
+

+ {t('chat.queryProgress.readingFilesDone')} +

+

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

+
+ ) + case 'reading-websites': + return ( +
+

+ {t('chat.queryProgress.readingWebsites')} + +

+ {state.currentUrl && ( +

+ {state.currentUrl} + {state.totalUrls && state.completedUrls !== undefined && ( + ({state.completedUrls}/{state.totalUrls}) + )} +

+ )} +
+ ) + case 'reading-websites-done': + return ( +
+

+ {t('chat.queryProgress.readingWebsitesDone')} +

+

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

+
+ ) case 'indexing': return (
diff --git a/src/components/chat-view/WebsiteReadResults.tsx b/src/components/chat-view/WebsiteReadResults.tsx new file mode 100644 index 0000000..79f0104 --- /dev/null +++ b/src/components/chat-view/WebsiteReadResults.tsx @@ -0,0 +1,95 @@ +import { ChevronDown, ChevronRight, FileText, Globe } from 'lucide-react' +import { TFile } from 'obsidian' +import { useState } from 'react' + +import { useApp } from '../../contexts/AppContext' +import { t } from '../../lang/helpers' + +function WebsiteReadItem({ + websiteResult, +}: { + websiteResult: { url: string, content: string } +}) { + const app = useApp() + + const handleClick = () => { + // 现在url字段实际上是markdown文件路径,直接在Obsidian中打开 + const file = app.vault.getAbstractFileByPath(websiteResult.url) + if (file instanceof TFile) { + app.workspace.getLeaf('tab').openFile(file) + } + } + + const getContentSize = (content: string) => { + const bytes = new Blob([content]).size + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + } + + const getFileBaseName = (filePath: string) => { + return filePath.split('/').pop()?.replace('.md', '') || 'website' + } + + const truncatePath = (filePath: string, maxLength: number = 60) => { + if (filePath.length <= maxLength) return filePath + return '...' + filePath.substring(filePath.length - maxLength) + } + + return ( +
+
+ +
+
+
+ {getFileBaseName(websiteResult.url)} +
+
+ {truncatePath(websiteResult.url)} +
+
+
+
+ {getContentSize(websiteResult.content)} +
+
+
+ ) +} + +export default function WebsiteReadResults({ + websiteContents, +}: { + websiteContents: Array<{ url: string, content: string }> +}) { + const [isOpen, setIsOpen] = useState(false) + + return ( +
+
{ + setIsOpen(!isOpen) + }} + className="infio-website-read-results__trigger" + > + {isOpen ? : } +
+ {t('chat.websiteResults.showReadWebsites')} ({websiteContents.length}) +
+
+ {isOpen && ( +
+ {websiteContents.map((websiteResult, index) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/core/mcp/McpHub.ts b/src/core/mcp/McpHub.ts index 6fb5200..37b7911 100644 --- a/src/core/mcp/McpHub.ts +++ b/src/core/mcp/McpHub.ts @@ -202,7 +202,7 @@ export class McpHub { if (typeof config !== 'object' || config === null) { throw new Error("Server configuration must be an object."); } - + // 使用类型保护而不是类型断言 const configObj = config as Record; @@ -214,7 +214,7 @@ export class McpHub { if (hasStdioFields && hasSseFields) { throw new Error(mixedFieldsErrorMessage) } - + const mutableConfig = { ...configObj }; // Create a mutable copy // Check if it's a stdio or SSE config and add type if missing @@ -307,24 +307,24 @@ export class McpHub { getServers(): McpServer[] { // Only return enabled servers const standardServers = this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server) - + // 添加内置服务器(如果存在且未禁用) if (this.builtInConnection && !this.builtInConnection.server.disabled) { return [this.builtInConnection.server, ...standardServers] } - + return standardServers } getAllServers(): McpServer[] { // Return all servers regardless of state const standardServers = this.connections.map((conn) => conn.server) - + // 添加内置服务器(如果存在) if (this.builtInConnection) { return [this.builtInConnection.server, ...standardServers] } - + return standardServers } @@ -451,7 +451,7 @@ export class McpHub { console.warn("Error injecting env vars. Using original config.", e); configInjected = config; // Fallback to original config } - + if (configInjected.type === "stdio") { // Ensure cwd is set, default to plugin's root directory if not provided // Obsidian's DataAdapter doesn't have a direct `basePath`. @@ -1165,8 +1165,8 @@ export class McpHub { * @param source Whether to create in global or project scope (defaults to global) */ public async createServer( - name: string, - config: string, + name: string, + config: string, source: "global" | "project" = "global" ): Promise { try { @@ -1321,42 +1321,61 @@ export class McpHub { throw new Error("Built-in server is not connected") } - // 调用内置 API - const response = await fetch(`${INFIO_BASE_URL}/mcp/tools/call`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.plugin.settings.infioProvider.apiKey}`, - }, - body: JSON.stringify({ - name: toolName, - arguments: toolArguments || {}, - }), - }) + // 调用内置 API,设置 10 分钟超时 + const controller = new AbortController() + const timeoutId = setTimeout(() => { + controller.abort() + }, 10 * 60 * 1000) // 10 分钟超时 - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } + try { + const response = await fetch(`${INFIO_BASE_URL}/mcp/tools/call`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.plugin.settings.infioProvider.apiKey}`, + }, + body: JSON.stringify({ + name: toolName, + arguments: toolArguments || {}, + }), + signal: controller.signal, + }) - const result = await response.json() + clearTimeout(timeoutId) - // 转换为 McpToolCallResponse 格式 - return { - content: [{ - type: "text", - text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) - }], - isError: false, + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const result = await response.json() + + // 接口已经返回了 MCP 格式的内容数组,直接使用 + return { + content: Array.isArray(result) ? result : [result], + isError: false, + } + } catch (error) { + clearTimeout(timeoutId) + console.error(`Failed to call built-in tool ${toolName}:`, error) + // 特殊处理超时错误 + let errorMessage: string + if (error instanceof Error && error.name === 'AbortError') { + errorMessage = `请求超时:工具 ${toolName} 执行时间超过 10 分钟` + } else { + errorMessage = `Error calling built-in tool: ${error instanceof Error ? error.message : String(error)}` + } + + return { + content: [{ + type: "text", + text: errorMessage + }], + isError: true, + } } } catch (error) { console.error(`Failed to call built-in tool ${toolName}:`, error) - return { - content: [{ - type: "text", - text: `Error calling built-in tool: ${error instanceof Error ? error.message : String(error)}` - }], - isError: true, - } + throw error } } @@ -1471,10 +1490,10 @@ export class McpHub { } } this.connections = [] - + // 清理内置服务器连接 this.builtInConnection = null - + this.eventRefs.forEach((ref) => this.app.vault.offref(ref)) this.eventRefs = [] } @@ -1483,10 +1502,10 @@ export class McpHub { private async initializeBuiltInServer(): Promise { try { console.log("Initializing built-in server...") - + // 获取工具列表 const tools = await this.fetchBuiltInTools() - + // 创建内置服务器连接 this.builtInConnection = { server: { @@ -1500,7 +1519,7 @@ export class McpHub { resourceTemplates: [], // 内置服务器暂不支持资源模板 } } - + console.log(`Built-in server initialized with ${tools.length} tools`) } catch (error) { console.error("Failed to initialize built-in server:", error) @@ -1532,9 +1551,9 @@ export class McpHub { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } - + const tools: BuiltInToolResponse[] = await response.json() - + // 转换为 McpTool 格式 return tools.map((tool) => ({ name: tool.name, @@ -1547,4 +1566,40 @@ export class McpHub { throw error } } + + /** + * 检查内置服务器是否可用 + * @returns true 如果内置服务器已连接且未被禁用,否则返回 false + */ + public isBuiltInServerAvailable(): boolean { + return !!( + this.builtInConnection && + this.builtInConnection.server.status === "connected" && + !this.builtInConnection.server.disabled + ) + } + + /** + * 获取内置服务器的详细状态信息 + * @returns 包含内置服务器状态信息的对象,如果不存在则返回 null + */ + public getBuiltInServerStatus(): { + exists: boolean + status: "connecting" | "connected" | "disconnected" + disabled: boolean + toolsCount: number + error?: string + } | null { + if (!this.builtInConnection) { + return null + } + + return { + exists: true, + status: this.builtInConnection.server.status, + disabled: this.builtInConnection.server.disabled, + toolsCount: this.builtInConnection.server.tools?.length || 0, + error: this.builtInConnection.server.error, + } + } } diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index e339ae8..8a45540 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -40,6 +40,12 @@ export default { searchResults: { showReferencedDocuments: "Show Referenced Documents" }, + fileResults: { + showReadFiles: "Show Read Files" + }, + websiteResults: { + showReadWebsites: "Show Website Content Files" + }, LLMResponseInfoPopover: { header: "LLM response information", tokenCount: "Token count", @@ -53,6 +59,12 @@ export default { }, queryProgress: { readingMentionableFiles: "Reading mentioned files", + readingFiles: "Reading files", + readingFilesDone: "Files read successfully", + filesLoaded: "{count} files loaded", + readingWebsites: "Reading websites", + readingWebsitesDone: "Websites read successfully", + websitesLoaded: "{count} websites loaded", indexing: "Indexing", file: "file", chunkIndexed: "chunk indexed", diff --git a/src/lang/locale/zh-cn.ts b/src/lang/locale/zh-cn.ts index bd30cdb..c2f8dfb 100644 --- a/src/lang/locale/zh-cn.ts +++ b/src/lang/locale/zh-cn.ts @@ -41,6 +41,12 @@ export default { searchResults: { showReferencedDocuments: "显示引用的文档" }, + fileResults: { + showReadFiles: "显示读取的文件" + }, + websiteResults: { + showReadWebsites: "显示网页内容文件" + }, LLMResponseInfoPopover: { header: "LLM 响应信息", tokenCount: "Token 数量", @@ -54,6 +60,12 @@ export default { }, queryProgress: { readingMentionableFiles: "正在读取提及的文件", + readingFiles: "正在读取文件", + readingFilesDone: "文件读取完成", + filesLoaded: "已加载 {count} 个文件", + readingWebsites: "正在读取网页内容", + readingWebsitesDone: "网页内容读取完成", + websitesLoaded: "已加载 {count} 个网页", indexing: "正在索引", file: "文件", chunkIndexed: "块已索引", diff --git a/src/types/chat.ts b/src/types/chat.ts index 792d794..c78f7f2 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -18,6 +18,8 @@ export type ChatUserMessage = { similaritySearchResults?: (Omit & { similarity: number })[] + fileReadResults?: Array<{ path: string, content: string }> + websiteReadResults?: Array<{ url: string, content: string }> } export type ChatAssistantMessage = { @@ -45,6 +47,8 @@ export type SerializedChatUserMessage = { similaritySearchResults?: (Omit & { similarity: number })[] + fileReadResults?: Array<{ path: string, content: string }> + websiteReadResults?: Array<{ url: string, content: string }> } export type SerializedChatAssistantMessage = { diff --git a/src/utils/prompt-generator.ts b/src/utils/prompt-generator.ts index 175850a..7194858 100644 --- a/src/utils/prompt-generator.ts +++ b/src/utils/prompt-generator.ts @@ -1,4 +1,4 @@ -import { App, MarkdownView, TAbstractFile, TFile, TFolder, Vault, getLanguage, htmlToMarkdown, requestUrl } from 'obsidian' +import { App, MarkdownView, TAbstractFile, TFile, TFolder, Vault, getLanguage, htmlToMarkdown, normalizePath, requestUrl } from 'obsidian' import { editorStateToPlainText } from '../components/chat-view/chat-input/utils/editor-state-to-plain-text' import { QueryProgressState } from '../components/chat-view/QueryProgress' @@ -21,10 +21,8 @@ import { InfioSettings } from '../types/settings' import { CustomModePrompts, Mode, ModeConfig, getFullModeDetails } from "../utils/modes" import { - readTFileContent, - readMultipleTFiles, - getNestedFiles, - parsePdfContent + parsePdfContent, + readTFileContent } from './obsidian' import { tokenCount } from './token' import { isVideoUrl, isYoutubeUrl } from './video-detector' @@ -70,7 +68,11 @@ async function getFolderTreeContent(path: TFolder): Promise { } } -async function getFileOrFolderContent(path: TAbstractFile, vault: Vault, app?: App): Promise { +async function getFileOrFolderContent( + path: TAbstractFile, + vault: Vault, + app?: App +): Promise { try { if (path instanceof TFile) { if (path.extension === 'pdf') { @@ -184,7 +186,7 @@ export class PromptGenerator { } const isNewChat = messages.filter(message => message.role === 'user').length === 1 - const { promptContent, similaritySearchResults } = + const { promptContent, similaritySearchResults, fileReadResults, websiteReadResults } = await this.compileUserMessagePrompt({ isNewChat, message: lastUserMessage, @@ -198,6 +200,8 @@ export class PromptGenerator { ...lastUserMessage, promptContent, similaritySearchResults, + fileReadResults, + websiteReadResults, }, ] @@ -318,6 +322,8 @@ export class PromptGenerator { similaritySearchResults?: (Omit & { similarity: number })[] + fileReadResults?: Array<{ path: string, content: string }> + websiteReadResults?: Array<{ url: string, content: string }> }> { // Add environment details // const environmentDetails = isNewChat @@ -349,27 +355,130 @@ export class PromptGenerator { const taskPrompt = isNewChat ? `\n${query}\n` : `\n${query}\n` + // 收集所有读取结果用于显示 + const allFileReadResults: Array<{ path: string, content: string }> = [] + const allWebsiteReadResults: Array<{ url: string, content: string }> = [] + // user mention files const files = message.mentionables .filter((m): m is MentionableFile => m.type === 'file') .map((m) => m.file) - let fileContentsPrompts = files.length > 0 - ? (await Promise.all(files.map(async (file) => { - const content = await getFileOrFolderContent(file, this.app.vault, this.app) - return `\n${content}\n` - }))).join('\n') - : undefined + let fileContentsPrompts: string | undefined = undefined + if (files.length > 0) { + // 初始化文件读取进度 + onQueryProgressChange?.({ + type: 'reading-files', + totalFiles: files.length, + completedFiles: 0 + }) + + // 确保UI有时间显示初始状态 + await new Promise(resolve => setTimeout(resolve, 100)) + + const fileContents: string[] = [] + const fileContentsForProgress: Array<{ path: string, content: string }> = [] + let completedFiles = 0 + + for (const file of files) { + // 更新当前正在读取的文件 + onQueryProgressChange?.({ + type: 'reading-files', + currentFile: file.path, + totalFiles: files.length, + completedFiles: completedFiles + }) + + const content = await getFileOrFolderContent( + file, + this.app.vault, + this.app + ) + + // 创建Markdown文件 + const markdownFilePath = await this.createMarkdownFileForContent( + file.path, + content, + false + ) + + completedFiles++ + fileContents.push(`\n${content}\n`) + fileContentsForProgress.push({ path: markdownFilePath, content }) + allFileReadResults.push({ path: markdownFilePath, content }) + } + + // 文件读取完成 + onQueryProgressChange?.({ + type: 'reading-files-done', + fileContents: fileContentsForProgress + }) + + // 让用户看到完成状态 + await new Promise(resolve => setTimeout(resolve, 200)) + + fileContentsPrompts = fileContents.join('\n') + } // user mention folders const folders = message.mentionables .filter((m): m is MentionableFolder => m.type === 'folder') .map((m) => m.folder) - let folderContentsPrompts = folders.length > 0 - ? (await Promise.all(folders.map(async (folder) => { - const content = await getFileOrFolderContent(folder, this.app.vault, this.app) - return `\n${content}\n` - }))).join('\n') - : undefined + let folderContentsPrompts: string | undefined = undefined + if (folders.length > 0) { + // 初始化文件夹读取进度(如果之前没有文件需要读取) + if (files.length === 0) { + onQueryProgressChange?.({ + type: 'reading-files', + totalFiles: folders.length, + completedFiles: 0 + }) + } + + const folderContents: string[] = [] + const folderContentsForProgress: Array<{ path: string, content: string }> = [] + let completedFolders = 0 + + for (const folder of folders) { + // 更新当前正在读取的文件夹 + onQueryProgressChange?.({ + type: 'reading-files', + currentFile: folder.path, + totalFiles: folders.length, + completedFiles: completedFolders + }) + + const content = await getFileOrFolderContent( + folder, + this.app.vault, + this.app + ) + + // 为文件夹内容创建Markdown文件 + const markdownFilePath = await this.createMarkdownFileForContent( + `${folder.path}/folder-contents`, + content, + false + ) + + completedFolders++ + folderContents.push(`\n${content}\n`) + folderContentsForProgress.push({ path: markdownFilePath, content }) + allFileReadResults.push({ path: markdownFilePath, content }) + } + + // 文件夹读取完成(如果之前没有文件需要读取) + if (files.length === 0) { + onQueryProgressChange?.({ + type: 'reading-files-done', + fileContents: folderContentsForProgress + }) + + // 让用户看到完成状态 + await new Promise(resolve => setTimeout(resolve, 200)) + } + + folderContentsPrompts = folderContents.join('\n') + } // user mention blocks const blocks = message.mentionables.filter( @@ -388,16 +497,62 @@ export class PromptGenerator { const urls = message.mentionables.filter( (m): m is MentionableUrl => m.type === 'url', ) - const urlContents = await Promise.all( - urls.map(async ({ url }) => ({ - url, - content: await this.getWebsiteContent(url) - })) - ) + const urlContents: Array<{ url: string, content: string }> = [] + if (urls.length > 0) { + // 初始化网页读取进度 + onQueryProgressChange?.({ + type: 'reading-websites', + totalUrls: urls.length, + completedUrls: 0 + }) + + // 确保UI有时间显示初始状态 + await new Promise(resolve => setTimeout(resolve, 100)) + + let completedUrls = 0 + + const mcpHub = await this.getMcpHub() + + for (const { url } of urls) { + // 更新当前正在读取的网页 + onQueryProgressChange?.({ + type: 'reading-websites', + currentUrl: url, + totalUrls: urls.length, + completedUrls: completedUrls + }) + + const content = await this.getWebsiteContent(url, mcpHub) + + // 从内容中提取标题 + const websiteTitle = this.extractTitleFromWebsiteContent(content, url) + + // 为网页内容创建Markdown文件 + const markdownFilePath = await this.createMarkdownFileForContent( + url, + content, + true, + websiteTitle + ) + + completedUrls++ + urlContents.push({ url: markdownFilePath, content }) // 这里url改为markdownFilePath + allWebsiteReadResults.push({ url: markdownFilePath, content }) // 同样这里也改为markdownFilePath + } + + // 网页读取完成 + onQueryProgressChange?.({ + type: 'reading-websites-done', + websiteContents: urlContents + }) + + // 让用户看到完成状态 + await new Promise(resolve => setTimeout(resolve, 200)) + } const urlContentsPrompt = urlContents.length > 0 ? urlContents .map(({ url, content }) => ( - `\n${content}\n` + `\n${content}\n` )) .join('\n') : undefined @@ -405,9 +560,51 @@ export class PromptGenerator { const currentFile = message.mentionables .filter((m): m is MentionableFile => m.type === 'current-file') .first() - const currentFileContent = currentFile && currentFile.file != null - ? await getFileOrFolderContent(currentFile.file, this.app.vault, this.app) - : undefined + let currentFileContent: string | undefined = undefined + if (currentFile && currentFile.file != null) { + // 初始化当前文件读取进度(如果之前没有其他文件或文件夹需要读取) + if (files.length === 0 && folders.length === 0) { + onQueryProgressChange?.({ + type: 'reading-files', + currentFile: currentFile.file.path, + totalFiles: 1, + completedFiles: 0 + }) + } + + // 如果当前文件不是 md 文件且 mcpHub 存在,使用 MCP 工具转换 + const mcpHub = await this.getMcpHub?.() + if (currentFile.file.extension !== 'md' && mcpHub?.isBuiltInServerAvailable()) { + currentFileContent = await this.callMcpToolConvertDocument(currentFile.file, mcpHub) + } else { + currentFileContent = await getFileOrFolderContent( + currentFile.file, + this.app.vault, + this.app + ) + } + + // 为当前文件创建Markdown文件 + const currentMarkdownFilePath = await this.createMarkdownFileForContent( + currentFile.file.path, + currentFileContent, + false + ) + + // 添加当前文件到读取结果中 + allFileReadResults.push({ path: currentMarkdownFilePath, content: currentFileContent }) + + // 当前文件读取完成(如果之前没有其他文件或文件夹需要读取) + if (files.length === 0 && folders.length === 0) { + onQueryProgressChange?.({ + type: 'reading-files-done', + fileContents: [{ path: currentMarkdownFilePath, content: currentFileContent }] + }) + + // 让用户看到完成状态 + await new Promise(resolve => setTimeout(resolve, 200)) + } + } // Check if current file content should be included let shouldIncludeCurrentFile = false @@ -458,15 +655,19 @@ export class PromptGenerator { fileContentsPrompts = files.map((file) => { return `\n(Content omitted due to token limit. Relevant sections will be provided by semantic search below.)\n` }).join('\n') - folderContentsPrompts = folders.map(async (folder) => { + folderContentsPrompts = (await Promise.all(folders.map(async (folder) => { const tree_content = await getFolderTreeContent(folder) return `\n${tree_content}\n(Content omitted due to token limit. Relevant sections will be provided by semantic search below.)\n` - }).join('\n') + }))).join('\n') } const shouldUseRAG = useVaultSearch || isOverThreshold let similaritySearchContents if (shouldUseRAG) { + // 重置进度状态,准备进入RAG阶段 + onQueryProgressChange?.({ + type: 'reading-mentionables', + }) similaritySearchResults = useVaultSearch ? await ( await this.getRagEngine() @@ -530,6 +731,8 @@ export class PromptGenerator { ) ], similaritySearchResults, + fileReadResults: allFileReadResults.length > 0 ? allFileReadResults : undefined, + websiteReadResults: allWebsiteReadResults.length > 0 ? allWebsiteReadResults : undefined, } } @@ -773,7 +976,14 @@ When writing out new markdown blocks, remember not to include "line_number|" at * - filter visually hidden elements * ... */ - private async getWebsiteContent(url: string): Promise { + private async getWebsiteContent(url: string, mcpHub: McpHub | null): Promise { + + const mcpHubAvailable = mcpHub?.isBuiltInServerAvailable() + + if (mcpHubAvailable && isVideoUrl(url)) { + return this.callMcpToolConvertVideo(url, mcpHub) + } + if (isYoutubeUrl(url)) { // TODO: pass language based on user preferences const { title, transcript } = @@ -789,25 +999,224 @@ ${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}` return htmlToMarkdown(response.text) } - private async callMcpToolGetWebsiteContent(url: string, mcpHub: McpHub | null): Promise { - if (isVideoUrl(url)) { - return this.callMcpToolConvertVideo(url, mcpHub) + private async callMcpToolConvertVideo(url: string, mcpHub: McpHub): Promise { + const response = await mcpHub.callTool( + 'infio-builtin-server', + 'CONVERT_VIDEO', + { url, detect_language: 'en' } + ) + + // 处理图片内容并获取图片引用 + const imageReferences = await this.processImagesInResponse(response.content) + + const textContent = response.content.find((c) => c.type === 'text') + let result = textContent?.text || '' + + // 在文本内容末尾添加图片引用 + if (imageReferences.length > 0) { + result += '\n\n## 图片内容\n\n' + imageReferences.forEach(imagePath => { + result += `![](${imagePath})\n\n` + }) } - return this.callMcpToolFetchUrlContent(url, mcpHub) + + return result } - private async callMcpToolConvertVideo(url: string, mcpHub: McpHub | null): Promise { - // TODO: implement - return '' + private async callMcpToolConvertDocument(file: TFile, mcpHub: McpHub): Promise { + // 读取文件的二进制内容并转换为Base64 + const fileBuffer = await this.app.vault.readBinary(file) + + // 安全地转换为Base64,避免堆栈溢出 + const uint8Array = new Uint8Array(fileBuffer) + let binaryString = '' + const chunkSize = 8192 // 处理块大小 + + for (let i = 0; i < uint8Array.length; i += chunkSize) { + const chunk = uint8Array.slice(i, i + chunkSize) + binaryString += String.fromCharCode.apply(null, Array.from(chunk)) + } + + const base64Content = btoa(binaryString) + + // 提取文件扩展名(不带点) + const fileType = file.extension + + const response = await mcpHub.callTool( + 'infio-builtin-server', + 'CONVERT_DOCUMENT', + { + file_content: base64Content, + file_type: fileType + } + ) + + // 处理图片内容并获取图片引用 + await this.processImagesInResponse(response.content) + + const textContent = response.content.find((c) => c.type === 'text') + const result = textContent?.text as string || '' + + return result } - private async callMcpToolFetchUrlContent(url: string, mcpHub: McpHub | null): Promise { - // TODO: implement - return '' + /** + * 为文件内容创建Markdown文件 + */ + private async createMarkdownFileForContent( + originalPath: string, + content: string, + isWebsite: boolean = false, + websiteTitle?: string + ): Promise { + try { + let targetPath: string + + if (isWebsite) { + // 网页内容保存到根目录 + const fileName = this.sanitizeFileName(websiteTitle || 'website') + targetPath = `${fileName}.md` + } else { + // 如果原文件已经是.md文件,直接返回原路径,不重复创建 + if (originalPath.endsWith('.md')) { + return originalPath + } + + // 文件内容保存到同路径下的.md文件 + const pathWithoutExt = originalPath.replace(/\.[^/.]+$/, "") + targetPath = `${pathWithoutExt}.md` + } + + // 处理文件名冲突 + targetPath = await this.getUniqueFilePath(targetPath) + + // 创建文件内容 + let markdownContent = content + if (isWebsite) { + markdownContent = `# ${websiteTitle || 'Website Content'}\n\n> Source: ${originalPath}\n\n${content}` + } else { + markdownContent = `# ${originalPath}\n\n${content}` + } + + // 创建文件 + const file = await this.app.vault.create(targetPath, markdownContent) + + // 在新标签页中打开文件 + this.app.workspace.getLeaf('tab').openFile(file) + + return file.path + } catch (error) { + console.error('Failed to create markdown file:', error) + return originalPath // 如果创建失败,返回原路径 + } } - private async callMcpToolConvertDocument(file: TFile, mcpHub: McpHub | null): Promise { - // TODO: implement - return '' + /** + * 清理文件名,移除不合法字符 + */ + private sanitizeFileName(fileName: string): string { + return fileName + .replace(/[<>:"/\\|?*]/g, '-') // 替换不合法字符 + .replace(/\s+/g, '-') // 替换空格 + .replace(/-+/g, '-') // 合并连续的横线 + .replace(/^-|-$/g, '') // 移除开头和结尾的横线 + .substring(0, 100) // 限制长度 + } + + /** + * 获取唯一的文件路径(处理重名冲突) + */ + private async getUniqueFilePath(targetPath: string): Promise { + const normalizedPath = normalizePath(targetPath) + + if (!this.app.vault.getAbstractFileByPath(normalizedPath)) { + return normalizedPath + } + + const pathParts = normalizedPath.split('.') + const extension = pathParts.pop() + const basePath = pathParts.join('.') + + let counter = 1 + let uniquePath: string + + do { + uniquePath = `${basePath}-${counter}.${extension}` + counter++ + } while (this.app.vault.getAbstractFileByPath(uniquePath)) + + return uniquePath + } + + /** + * 从网页内容中提取标题 + */ + private extractTitleFromWebsiteContent(content: string, url: string): string { + // 尝试从内容中提取标题 + const titleRegex1 = /^#\s+(.+)$/m + const titleRegex2 = /Title:\s*(.+)$/m + const titleMatch = titleRegex1.exec(content) || titleRegex2.exec(content) + if (titleMatch && titleMatch[1]) { + return titleMatch[1].trim() + } + + // 如果没有找到标题,使用域名 + try { + return new URL(url).hostname + } catch { + return 'website' + } + } + + /** + * 处理响应中的图片内容,将base64图片保存到Obsidian资源目录 + */ + private async processImagesInResponse(content: Array<{ type: string, data: string, filename: string, mimeType?: string }>): Promise { + const savedImagePaths: string[] = [] + + for (const item of content) { + if (item.type === 'image' && item.data && item.filename) { + try { + const imagePath = await this.saveImageFromBase64(item.data, item.filename, item.mimeType) + savedImagePaths.push(imagePath) + } catch (error) { + console.error('Failed to save image:', error) + } + } + } + + return savedImagePaths + } + + /** + * 将base64图片数据保存为文件到Obsidian资源目录 + */ + private async saveImageFromBase64(base64Data: string, filename: string, mimeType?: string): Promise { + // 获取默认资源目录 + const staticResourceDir = this.app.vault.getConfig("attachmentFolderPath") + + // 构建完整的文件路径 + const targetPath = staticResourceDir ? normalizePath(`${staticResourceDir}/${filename}`) : filename + + // 处理文件名冲突 + // const uniquePath = await this.getUniqueFilePath(targetPath) + + try { + // 将base64数据转换为ArrayBuffer + const binaryString = atob(base64Data) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + // 创建图片文件 + await this.app.vault.createBinary(targetPath, bytes.buffer) + + console.log(`Image saved: ${targetPath}`) + return targetPath + } catch (error) { + console.error(`Failed to save image to ${targetPath}:`, error) + throw error + } } } diff --git a/styles.css b/styles.css index 17004b9..588ba74 100644 --- a/styles.css +++ b/styles.css @@ -986,6 +986,152 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { } } +/* File Read Results */ +.infio-file-read-results { + display: flex; + flex-direction: column; + font-size: var(--font-smaller); + padding-top: var(--size-4-1); + padding-bottom: var(--size-4-1); + user-select: none; + + .infio-file-read-results__trigger { + display: flex; + align-items: center; + gap: var(--size-4-1); + padding: var(--size-4-1); + border-radius: var(--radius-s); + cursor: pointer; + &:hover { + background-color: var(--background-modifier-hover); + } + } + + .infio-file-read-item { + display: flex; + align-items: center; + justify-content: start; + gap: var(--size-4-2); + padding: var(--size-4-1); + border-radius: var(--radius-s); + cursor: pointer; + + &:hover { + background-color: var(--background-modifier-hover); + } + + .infio-file-read-item__icon { + flex-shrink: 0; + color: var(--text-muted); + } + + .infio-file-read-item__info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--size-2-1); + } + + .infio-file-read-item__name { + font-size: var(--font-smallest); + font-weight: var(--font-medium); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .infio-file-read-item__path { + font-size: var(--font-smallest); + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .infio-file-read-item__size { + flex-shrink: 0; + font-size: var(--font-smallest); + color: var(--text-muted); + } + } +} + +/* Website Read Results */ +.infio-website-read-results { + display: flex; + flex-direction: column; + font-size: var(--font-smaller); + padding-top: var(--size-4-1); + padding-bottom: var(--size-4-1); + user-select: none; + + .infio-website-read-results__trigger { + display: flex; + align-items: center; + gap: var(--size-4-1); + padding: var(--size-4-1); + border-radius: var(--radius-s); + cursor: pointer; + &:hover { + background-color: var(--background-modifier-hover); + } + } + + .infio-website-read-item { + display: flex; + align-items: center; + justify-content: start; + gap: var(--size-4-2); + padding: var(--size-4-1); + border-radius: var(--radius-s); + cursor: pointer; + + &:hover { + background-color: var(--background-modifier-hover); + } + + .infio-website-read-item__icon { + flex-shrink: 0; + color: var(--text-muted); + } + + .infio-website-read-item__info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--size-2-1); + } + + .infio-website-read-item__domain { + font-size: var(--font-smallest); + font-weight: var(--font-medium); + color: var(--text-accent); + } + + .infio-website-read-item__url { + font-size: var(--font-smallest); + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .infio-website-read-item__actions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--size-4-1); + } + + .infio-website-read-item__size { + font-size: var(--font-smallest); + color: var(--text-muted); + } + } +} + /* * LLM Info */