update buildin mcp tools
This commit is contained in:
parent
2b571f67a7
commit
4053214078
@ -64,6 +64,8 @@ import McpHubView from './McpHubView' // Moved after MarkdownReasoningBlock
|
|||||||
import QueryProgress, { QueryProgressState } from './QueryProgress'
|
import QueryProgress, { QueryProgressState } from './QueryProgress'
|
||||||
import ReactMarkdown from './ReactMarkdown'
|
import ReactMarkdown from './ReactMarkdown'
|
||||||
import SimilaritySearchResults from './SimilaritySearchResults'
|
import SimilaritySearchResults from './SimilaritySearchResults'
|
||||||
|
import FileReadResults from './FileReadResults'
|
||||||
|
import WebsiteReadResults from './WebsiteReadResults'
|
||||||
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
|
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
|
||||||
|
|
||||||
// Add an empty line here
|
// 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 && (
|
{message.similaritySearchResults && (
|
||||||
<SimilaritySearchResults
|
<SimilaritySearchResults
|
||||||
key={"similarity-search-" + message.id}
|
key={"similarity-search-" + message.id}
|
||||||
|
|||||||
82
src/components/chat-view/FileReadResults.tsx
Normal file
82
src/components/chat-view/FileReadResults.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -4,6 +4,26 @@ export type QueryProgressState =
|
|||||||
| {
|
| {
|
||||||
type: 'reading-mentionables'
|
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'
|
type: 'indexing'
|
||||||
indexProgress: IndexProgress
|
indexProgress: IndexProgress
|
||||||
@ -43,6 +63,62 @@ export default function QueryProgress({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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':
|
case 'indexing':
|
||||||
return (
|
return (
|
||||||
<div className="infio-query-progress">
|
<div className="infio-query-progress">
|
||||||
|
|||||||
95
src/components/chat-view/WebsiteReadResults.tsx
Normal file
95
src/components/chat-view/WebsiteReadResults.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1321,42 +1321,61 @@ export class McpHub {
|
|||||||
throw new Error("Built-in server is not connected")
|
throw new Error("Built-in server is not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用内置 API
|
// 调用内置 API,设置 10 分钟超时
|
||||||
const response = await fetch(`${INFIO_BASE_URL}/mcp/tools/call`, {
|
const controller = new AbortController()
|
||||||
method: 'POST',
|
const timeoutId = setTimeout(() => {
|
||||||
headers: {
|
controller.abort()
|
||||||
'Content-Type': 'application/json',
|
}, 10 * 60 * 1000) // 10 分钟超时
|
||||||
'Authorization': `Bearer ${this.plugin.settings.infioProvider.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: toolName,
|
|
||||||
arguments: toolArguments || {},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
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 格式
|
if (!response.ok) {
|
||||||
return {
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
content: [{
|
}
|
||||||
type: "text",
|
|
||||||
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
const result = await response.json()
|
||||||
}],
|
|
||||||
isError: false,
|
// 接口已经返回了 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) {
|
} catch (error) {
|
||||||
console.error(`Failed to call built-in tool ${toolName}:`, error)
|
console.error(`Failed to call built-in tool ${toolName}:`, error)
|
||||||
return {
|
throw error
|
||||||
content: [{
|
|
||||||
type: "text",
|
|
||||||
text: `Error calling built-in tool: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
}],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1547,4 +1566,40 @@ export class McpHub {
|
|||||||
throw error
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,12 @@ export default {
|
|||||||
searchResults: {
|
searchResults: {
|
||||||
showReferencedDocuments: "Show Referenced Documents"
|
showReferencedDocuments: "Show Referenced Documents"
|
||||||
},
|
},
|
||||||
|
fileResults: {
|
||||||
|
showReadFiles: "Show Read Files"
|
||||||
|
},
|
||||||
|
websiteResults: {
|
||||||
|
showReadWebsites: "Show Website Content Files"
|
||||||
|
},
|
||||||
LLMResponseInfoPopover: {
|
LLMResponseInfoPopover: {
|
||||||
header: "LLM response information",
|
header: "LLM response information",
|
||||||
tokenCount: "Token count",
|
tokenCount: "Token count",
|
||||||
@ -53,6 +59,12 @@ export default {
|
|||||||
},
|
},
|
||||||
queryProgress: {
|
queryProgress: {
|
||||||
readingMentionableFiles: "Reading mentioned files",
|
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",
|
indexing: "Indexing",
|
||||||
file: "file",
|
file: "file",
|
||||||
chunkIndexed: "chunk indexed",
|
chunkIndexed: "chunk indexed",
|
||||||
|
|||||||
@ -41,6 +41,12 @@ export default {
|
|||||||
searchResults: {
|
searchResults: {
|
||||||
showReferencedDocuments: "显示引用的文档"
|
showReferencedDocuments: "显示引用的文档"
|
||||||
},
|
},
|
||||||
|
fileResults: {
|
||||||
|
showReadFiles: "显示读取的文件"
|
||||||
|
},
|
||||||
|
websiteResults: {
|
||||||
|
showReadWebsites: "显示网页内容文件"
|
||||||
|
},
|
||||||
LLMResponseInfoPopover: {
|
LLMResponseInfoPopover: {
|
||||||
header: "LLM 响应信息",
|
header: "LLM 响应信息",
|
||||||
tokenCount: "Token 数量",
|
tokenCount: "Token 数量",
|
||||||
@ -54,6 +60,12 @@ export default {
|
|||||||
},
|
},
|
||||||
queryProgress: {
|
queryProgress: {
|
||||||
readingMentionableFiles: "正在读取提及的文件",
|
readingMentionableFiles: "正在读取提及的文件",
|
||||||
|
readingFiles: "正在读取文件",
|
||||||
|
readingFilesDone: "文件读取完成",
|
||||||
|
filesLoaded: "已加载 {count} 个文件",
|
||||||
|
readingWebsites: "正在读取网页内容",
|
||||||
|
readingWebsitesDone: "网页内容读取完成",
|
||||||
|
websitesLoaded: "已加载 {count} 个网页",
|
||||||
indexing: "正在索引",
|
indexing: "正在索引",
|
||||||
file: "文件",
|
file: "文件",
|
||||||
chunkIndexed: "块已索引",
|
chunkIndexed: "块已索引",
|
||||||
|
|||||||
@ -18,6 +18,8 @@ export type ChatUserMessage = {
|
|||||||
similaritySearchResults?: (Omit<SelectVector, 'embedding'> & {
|
similaritySearchResults?: (Omit<SelectVector, 'embedding'> & {
|
||||||
similarity: number
|
similarity: number
|
||||||
})[]
|
})[]
|
||||||
|
fileReadResults?: Array<{ path: string, content: string }>
|
||||||
|
websiteReadResults?: Array<{ url: string, content: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatAssistantMessage = {
|
export type ChatAssistantMessage = {
|
||||||
@ -45,6 +47,8 @@ export type SerializedChatUserMessage = {
|
|||||||
similaritySearchResults?: (Omit<SelectVector, 'embedding'> & {
|
similaritySearchResults?: (Omit<SelectVector, 'embedding'> & {
|
||||||
similarity: number
|
similarity: number
|
||||||
})[]
|
})[]
|
||||||
|
fileReadResults?: Array<{ path: string, content: string }>
|
||||||
|
websiteReadResults?: Array<{ url: string, content: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SerializedChatAssistantMessage = {
|
export type SerializedChatAssistantMessage = {
|
||||||
|
|||||||
@ -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 { editorStateToPlainText } from '../components/chat-view/chat-input/utils/editor-state-to-plain-text'
|
||||||
import { QueryProgressState } from '../components/chat-view/QueryProgress'
|
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 { CustomModePrompts, Mode, ModeConfig, getFullModeDetails } from "../utils/modes"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
readTFileContent,
|
parsePdfContent,
|
||||||
readMultipleTFiles,
|
readTFileContent
|
||||||
getNestedFiles,
|
|
||||||
parsePdfContent
|
|
||||||
} from './obsidian'
|
} from './obsidian'
|
||||||
import { tokenCount } from './token'
|
import { tokenCount } from './token'
|
||||||
import { isVideoUrl, isYoutubeUrl } from './video-detector'
|
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 {
|
try {
|
||||||
if (path instanceof TFile) {
|
if (path instanceof TFile) {
|
||||||
if (path.extension === 'pdf') {
|
if (path.extension === 'pdf') {
|
||||||
@ -184,7 +186,7 @@ export class PromptGenerator {
|
|||||||
}
|
}
|
||||||
const isNewChat = messages.filter(message => message.role === 'user').length === 1
|
const isNewChat = messages.filter(message => message.role === 'user').length === 1
|
||||||
|
|
||||||
const { promptContent, similaritySearchResults } =
|
const { promptContent, similaritySearchResults, fileReadResults, websiteReadResults } =
|
||||||
await this.compileUserMessagePrompt({
|
await this.compileUserMessagePrompt({
|
||||||
isNewChat,
|
isNewChat,
|
||||||
message: lastUserMessage,
|
message: lastUserMessage,
|
||||||
@ -198,6 +200,8 @@ export class PromptGenerator {
|
|||||||
...lastUserMessage,
|
...lastUserMessage,
|
||||||
promptContent,
|
promptContent,
|
||||||
similaritySearchResults,
|
similaritySearchResults,
|
||||||
|
fileReadResults,
|
||||||
|
websiteReadResults,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -318,6 +322,8 @@ export class PromptGenerator {
|
|||||||
similaritySearchResults?: (Omit<SelectVector, 'embedding'> & {
|
similaritySearchResults?: (Omit<SelectVector, 'embedding'> & {
|
||||||
similarity: number
|
similarity: number
|
||||||
})[]
|
})[]
|
||||||
|
fileReadResults?: Array<{ path: string, content: string }>
|
||||||
|
websiteReadResults?: Array<{ url: string, content: string }>
|
||||||
}> {
|
}> {
|
||||||
// Add environment details
|
// Add environment details
|
||||||
// const environmentDetails = isNewChat
|
// const environmentDetails = isNewChat
|
||||||
@ -349,27 +355,130 @@ export class PromptGenerator {
|
|||||||
|
|
||||||
const taskPrompt = isNewChat ? `<task>\n${query}\n</task>` : `<feedback>\n${query}\n</feedback>`
|
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
|
// user mention files
|
||||||
const files = message.mentionables
|
const files = message.mentionables
|
||||||
.filter((m): m is MentionableFile => m.type === 'file')
|
.filter((m): m is MentionableFile => m.type === 'file')
|
||||||
.map((m) => m.file)
|
.map((m) => m.file)
|
||||||
let fileContentsPrompts = files.length > 0
|
let fileContentsPrompts: string | undefined = undefined
|
||||||
? (await Promise.all(files.map(async (file) => {
|
if (files.length > 0) {
|
||||||
const content = await getFileOrFolderContent(file, this.app.vault, this.app)
|
// 初始化文件读取进度
|
||||||
return `<file_content path="${file.path}">\n${content}\n</file_content>`
|
onQueryProgressChange?.({
|
||||||
}))).join('\n')
|
type: 'reading-files',
|
||||||
: undefined
|
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
|
// user mention folders
|
||||||
const folders = message.mentionables
|
const folders = message.mentionables
|
||||||
.filter((m): m is MentionableFolder => m.type === 'folder')
|
.filter((m): m is MentionableFolder => m.type === 'folder')
|
||||||
.map((m) => m.folder)
|
.map((m) => m.folder)
|
||||||
let folderContentsPrompts = folders.length > 0
|
let folderContentsPrompts: string | undefined = undefined
|
||||||
? (await Promise.all(folders.map(async (folder) => {
|
if (folders.length > 0) {
|
||||||
const content = await getFileOrFolderContent(folder, this.app.vault, this.app)
|
// 初始化文件夹读取进度(如果之前没有文件需要读取)
|
||||||
return `<folder_content path="${folder.path}">\n${content}\n</folder_content>`
|
if (files.length === 0) {
|
||||||
}))).join('\n')
|
onQueryProgressChange?.({
|
||||||
: undefined
|
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
|
// user mention blocks
|
||||||
const blocks = message.mentionables.filter(
|
const blocks = message.mentionables.filter(
|
||||||
@ -388,16 +497,62 @@ export class PromptGenerator {
|
|||||||
const urls = message.mentionables.filter(
|
const urls = message.mentionables.filter(
|
||||||
(m): m is MentionableUrl => m.type === 'url',
|
(m): m is MentionableUrl => m.type === 'url',
|
||||||
)
|
)
|
||||||
const urlContents = await Promise.all(
|
const urlContents: Array<{ url: string, content: string }> = []
|
||||||
urls.map(async ({ url }) => ({
|
if (urls.length > 0) {
|
||||||
url,
|
// 初始化网页读取进度
|
||||||
content: await this.getWebsiteContent(url)
|
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
|
const urlContentsPrompt = urlContents.length > 0
|
||||||
? urlContents
|
? urlContents
|
||||||
.map(({ url, content }) => (
|
.map(({ url, content }) => (
|
||||||
`<url_content url="${url}">\n${content}\n</url_content>`
|
`<file_content path="${url}">\n${content}\n</file_content>`
|
||||||
))
|
))
|
||||||
.join('\n') : undefined
|
.join('\n') : undefined
|
||||||
|
|
||||||
@ -405,9 +560,51 @@ export class PromptGenerator {
|
|||||||
const currentFile = message.mentionables
|
const currentFile = message.mentionables
|
||||||
.filter((m): m is MentionableFile => m.type === 'current-file')
|
.filter((m): m is MentionableFile => m.type === 'current-file')
|
||||||
.first()
|
.first()
|
||||||
const currentFileContent = currentFile && currentFile.file != null
|
let currentFileContent: string | undefined = undefined
|
||||||
? await getFileOrFolderContent(currentFile.file, this.app.vault, this.app)
|
if (currentFile && currentFile.file != null) {
|
||||||
: undefined
|
// 初始化当前文件读取进度(如果之前没有其他文件或文件夹需要读取)
|
||||||
|
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
|
// Check if current file content should be included
|
||||||
let shouldIncludeCurrentFile = false
|
let shouldIncludeCurrentFile = false
|
||||||
@ -458,15 +655,19 @@ export class PromptGenerator {
|
|||||||
fileContentsPrompts = files.map((file) => {
|
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>`
|
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')
|
}).join('\n')
|
||||||
folderContentsPrompts = folders.map(async (folder) => {
|
folderContentsPrompts = (await Promise.all(folders.map(async (folder) => {
|
||||||
const tree_content = await getFolderTreeContent(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>`
|
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
|
const shouldUseRAG = useVaultSearch || isOverThreshold
|
||||||
let similaritySearchContents
|
let similaritySearchContents
|
||||||
if (shouldUseRAG) {
|
if (shouldUseRAG) {
|
||||||
|
// 重置进度状态,准备进入RAG阶段
|
||||||
|
onQueryProgressChange?.({
|
||||||
|
type: 'reading-mentionables',
|
||||||
|
})
|
||||||
similaritySearchResults = useVaultSearch
|
similaritySearchResults = useVaultSearch
|
||||||
? await (
|
? await (
|
||||||
await this.getRagEngine()
|
await this.getRagEngine()
|
||||||
@ -530,6 +731,8 @@ export class PromptGenerator {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
similaritySearchResults,
|
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
|
* - 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)) {
|
if (isYoutubeUrl(url)) {
|
||||||
// TODO: pass language based on user preferences
|
// TODO: pass language based on user preferences
|
||||||
const { title, transcript } =
|
const { title, transcript } =
|
||||||
@ -789,25 +999,224 @@ ${transcript.map((t) => `${t.offset}: ${t.text}`).join('\n')}`
|
|||||||
return htmlToMarkdown(response.text)
|
return htmlToMarkdown(response.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async callMcpToolGetWebsiteContent(url: string, mcpHub: McpHub | null): Promise<string> {
|
private async callMcpToolConvertVideo(url: string, mcpHub: McpHub): Promise<string> {
|
||||||
if (isVideoUrl(url)) {
|
const response = await mcpHub.callTool(
|
||||||
return this.callMcpToolConvertVideo(url, mcpHub)
|
'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<string> {
|
private async callMcpToolConvertDocument(file: TFile, mcpHub: McpHub): Promise<string> {
|
||||||
// TODO: implement
|
// 读取文件的二进制内容并转换为Base64
|
||||||
return ''
|
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<string> {
|
/**
|
||||||
// TODO: implement
|
* 为文件内容创建Markdown文件
|
||||||
return ''
|
*/
|
||||||
|
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 async callMcpToolConvertDocument(file: TFile, mcpHub: McpHub | null): Promise<string> {
|
/**
|
||||||
// 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<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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
146
styles.css
146
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
|
* LLM Info
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user