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 += `\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
*/