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 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}
|
||||
|
||||
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-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">
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -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<string, unknown>;
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: "块已索引",
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 }) => ({
|
||||
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 }) => (
|
||||
`<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)
|
||||
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 += `\n\n`
|
||||
})
|
||||
}
|
||||
return this.callMcpToolFetchUrlContent(url, mcpHub)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async callMcpToolConvertVideo(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))
|
||||
}
|
||||
|
||||
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
|
||||
return ''
|
||||
/**
|
||||
* 为文件内容创建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 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
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user