update buildin mcp tools

This commit is contained in:
duanfuxiang 2025-06-08 22:33:01 +08:00
parent 2b571f67a7
commit 4053214078
10 changed files with 996 additions and 91 deletions

View File

@ -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<ChatRef, ChatProps>((props, ref) => {
)
}}
/>
{message.fileReadResults && (
<FileReadResults
key={"file-read-" + message.id}
fileContents={message.fileReadResults}
/>
)}
{message.websiteReadResults && (
<WebsiteReadResults
key={"website-read-" + message.id}
websiteContents={message.websiteReadResults}
/>
)}
{message.similaritySearchResults && (
<SimilaritySearchResults
key={"similarity-search-" + message.id}

View File

@ -0,0 +1,82 @@
import path from 'path'
import { ChevronDown, ChevronRight, FileText } from 'lucide-react'
import { useState } from 'react'
import { useApp } from '../../contexts/AppContext'
import { t } from '../../lang/helpers'
import { openMarkdownFile } from '../../utils/obsidian'
function FileReadItem({
fileResult,
}: {
fileResult: { path: string, content: string }
}) {
const app = useApp()
const handleClick = () => {
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 (
<div onClick={handleClick} className="infio-file-read-item">
<div className="infio-file-read-item__icon">
<FileText size={16} />
</div>
<div className="infio-file-read-item__info">
<div className="infio-file-read-item__name">
{path.basename(fileResult.path)}
</div>
<div className="infio-file-read-item__path">
{fileResult.path}
</div>
</div>
<div className="infio-file-read-item__size">
{getFileSize(fileResult.content)}
</div>
</div>
)
}
export default function FileReadResults({
fileContents,
}: {
fileContents: Array<{ path: string, content: string }>
}) {
const [isOpen, setIsOpen] = useState(false)
return (
<div className="infio-file-read-results">
<div
onClick={() => {
setIsOpen(!isOpen)
}}
className="infio-file-read-results__trigger"
>
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<div>
{t('chat.fileResults.showReadFiles')} ({fileContents.length})
</div>
</div>
{isOpen && (
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
{fileContents.map((fileResult, index) => (
<FileReadItem key={`${fileResult.path}-${index}`} fileResult={fileResult} />
))}
</div>
)}
</div>
)
}

View File

@ -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({
</p>
</div>
)
case 'reading-files':
return (
<div className="infio-query-progress">
<p>
{t('chat.queryProgress.readingFiles')}
<DotLoader />
</p>
{state.currentFile && (
<p className="infio-query-progress-detail">
{state.currentFile}
{state.totalFiles && state.completedFiles !== undefined && (
<span> ({state.completedFiles}/{state.totalFiles})</span>
)}
</p>
)}
</div>
)
case 'reading-files-done':
return (
<div className="infio-query-progress">
<p>
{t('chat.queryProgress.readingFilesDone')}
</p>
<p className="infio-query-progress-detail">
{t('chat.queryProgress.filesLoaded', { count: state.fileContents.length })}
</p>
</div>
)
case 'reading-websites':
return (
<div className="infio-query-progress">
<p>
{t('chat.queryProgress.readingWebsites')}
<DotLoader />
</p>
{state.currentUrl && (
<p className="infio-query-progress-detail">
{state.currentUrl}
{state.totalUrls && state.completedUrls !== undefined && (
<span> ({state.completedUrls}/{state.totalUrls})</span>
)}
</p>
)}
</div>
)
case 'reading-websites-done':
return (
<div className="infio-query-progress">
<p>
{t('chat.queryProgress.readingWebsitesDone')}
</p>
<p className="infio-query-progress-detail">
{t('chat.queryProgress.websitesLoaded', { count: state.websiteContents.length })}
</p>
</div>
)
case 'indexing':
return (
<div className="infio-query-progress">

View File

@ -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 (
<div onClick={handleClick} className="infio-website-read-item">
<div className="infio-website-read-item__icon">
<FileText size={16} />
</div>
<div className="infio-website-read-item__info">
<div className="infio-website-read-item__domain">
{getFileBaseName(websiteResult.url)}
</div>
<div className="infio-website-read-item__url">
{truncatePath(websiteResult.url)}
</div>
</div>
<div className="infio-website-read-item__actions">
<div className="infio-website-read-item__size">
{getContentSize(websiteResult.content)}
</div>
</div>
</div>
)
}
export default function WebsiteReadResults({
websiteContents,
}: {
websiteContents: Array<{ url: string, content: string }>
}) {
const [isOpen, setIsOpen] = useState(false)
return (
<div className="infio-website-read-results">
<div
onClick={() => {
setIsOpen(!isOpen)
}}
className="infio-website-read-results__trigger"
>
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<div>
{t('chat.websiteResults.showReadWebsites')} ({websiteContents.length})
</div>
</div>
{isOpen && (
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
{websiteContents.map((websiteResult, index) => (
<WebsiteReadItem key={`${websiteResult.url}-${index}`} websiteResult={websiteResult} />
))}
</div>
)}
</div>
)
}

View File

@ -1321,7 +1321,13 @@ export class McpHub {
throw new Error("Built-in server is not connected")
}
// 调用内置 API
// 调用内置 API设置 10 分钟超时
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, 10 * 60 * 1000) // 10 分钟超时
try {
const response = await fetch(`${INFIO_BASE_URL}/mcp/tools/call`, {
method: 'POST',
headers: {
@ -1332,32 +1338,45 @@ export class McpHub {
name: toolName,
arguments: toolArguments || {},
}),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json()
// 转换为 McpToolCallResponse 格式
// 接口已经返回了 MCP 格式的内容数组,直接使用
return {
content: [{
type: "text",
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
}],
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: `Error calling built-in tool: ${error instanceof Error ? error.message : String(error)}`
text: errorMessage
}],
isError: true,
}
}
} catch (error) {
console.error(`Failed to call built-in tool ${toolName}:`, error)
throw error
}
}
async toggleToolAlwaysAllow(
@ -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,
}
}
}

View File

@ -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",

View File

@ -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: "块已索引",

View File

@ -18,6 +18,8 @@ export type ChatUserMessage = {
similaritySearchResults?: (Omit<SelectVector, 'embedding'> & {
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<SelectVector, 'embedding'> & {
similarity: number
})[]
fileReadResults?: Array<{ path: string, content: string }>
websiteReadResults?: Array<{ url: string, content: string }>
}
export type SerializedChatAssistantMessage = {

View File

@ -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<string> {
}
}
async function getFileOrFolderContent(path: TAbstractFile, vault: Vault, app?: App): Promise<string> {
async function getFileOrFolderContent(
path: TAbstractFile,
vault: Vault,
app?: App
): Promise<string> {
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<SelectVector, 'embedding'> & {
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 ? `<task>\n${query}\n</task>` : `<feedback>\n${query}\n</feedback>`
// 收集所有读取结果用于显示
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 `<file_content path="${file.path}">\n${content}\n</file_content>`
}))).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(`<file_content path="${file.path}">\n${content}\n</file_content>`)
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 `<folder_content path="${folder.path}">\n${content}\n</folder_content>`
}))).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(`<folder_content path="${folder.path}">\n${content}\n</folder_content>`)
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 }) => ({
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: await this.getWebsiteContent(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 }) => (
`<url_content url="${url}">\n${content}\n</url_content>`
`<file_content path="${url}">\n${content}\n</file_content>`
))
.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 `<file_content path="${file.path}">\n(Content omitted due to token limit. Relevant sections will be provided by semantic search below.)\n</file_content>`
}).join('\n')
folderContentsPrompts = folders.map(async (folder) => {
folderContentsPrompts = (await Promise.all(folders.map(async (folder) => {
const tree_content = await getFolderTreeContent(folder)
return `<folder_content path="${folder.path}">\n${tree_content}\n(Content omitted due to token limit. Relevant sections will be provided by semantic search below.)\n</folder_content>`
}).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<string> {
private async getWebsiteContent(url: string, mcpHub: McpHub | null): Promise<string> {
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<string> {
if (isVideoUrl(url)) {
return this.callMcpToolConvertVideo(url, mcpHub)
}
return this.callMcpToolFetchUrlContent(url, mcpHub)
private async callMcpToolConvertVideo(url: string, mcpHub: McpHub): Promise<string> {
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`
})
}
private async callMcpToolConvertVideo(url: string, mcpHub: McpHub | null): Promise<string> {
// TODO: implement
return ''
return result
}
private async callMcpToolFetchUrlContent(url: string, mcpHub: McpHub | null): Promise<string> {
// TODO: implement
return ''
private async callMcpToolConvertDocument(file: TFile, mcpHub: McpHub): Promise<string> {
// 读取文件的二进制内容并转换为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))
}
private async callMcpToolConvertDocument(file: TFile, mcpHub: McpHub | null): Promise<string> {
// TODO: implement
return ''
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
}
/**
* Markdown文件
*/
private async createMarkdownFileForContent(
originalPath: string,
content: string,
isWebsite: boolean = false,
websiteTitle?: string
): Promise<string> {
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 sanitizeFileName(fileName: string): string {
return fileName
.replace(/[<>:"/\\|?*]/g, '-') // 替换不合法字符
.replace(/\s+/g, '-') // 替换空格
.replace(/-+/g, '-') // 合并连续的横线
.replace(/^-|-$/g, '') // 移除开头和结尾的横线
.substring(0, 100) // 限制长度
}
/**
*
*/
private async getUniqueFilePath(targetPath: string): Promise<string> {
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<string[]> {
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<string> {
// 获取默认资源目录
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
}
}
}

View File

@ -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
*/