diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index e713fad15e..1e79630a09 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -1882,7 +1882,12 @@ async def chat_completion_files_handler( __event_emitter__ = extra_params['__event_emitter__'] sources = [] - if files := body.get('metadata', {}).get('files', None): + files = [ + item + for item in (body.get('metadata', {}).get('files', None) or []) + if item.get('type') != 'filesystem' + ] + if files: # Check if all files are in full context mode all_full_context = all(item.get('context') == 'full' for item in files) diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 4bec25fdf9..dd53620fd3 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -11,6 +11,14 @@ export type TerminalFileList = { writable?: boolean; }; +export type TerminalFileSearchResult = FileEntry & { + path: string; +}; + +export type TerminalFileSearchResponse = { + results: TerminalFileSearchResult[]; +}; + export type ListeningPort = { port: number; pid: number | null; @@ -38,6 +46,21 @@ const bearerHeaders = (apiKey: string): Record => ({ Authorization: `Bearer ${apiKey.trim()}` }); +const joinTerminalPath = (base: string, child: string) => { + if (!child) return base; + if (child.startsWith('/') || /^[A-Za-z]:[\\/]/.test(child)) return child; + return `${base.replace(/[\\/]+$/, '')}/${child.replace(/^[\\/]+/, '')}`; +}; + +const basename = (path: string) => + path.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? path; + +const hasHiddenPathPart = (path: string) => + path + .replace(/\\/g, '/') + .split('/') + .some((part) => part.startsWith('.')); + export type TerminalServer = { id: string; url: string; @@ -112,6 +135,65 @@ export const listFiles = async ( return res?.entries ? { entries: res.entries, writable: res.writable } : null; }; +export const searchFiles = async ( + baseUrl: string, + apiKey: string, + query: string, + path: string = '.', + limit: number = 20, + type: 'file' | 'directory' | 'any' = 'any', + sessionId?: string, + showHidden: boolean = false +): Promise => { + const headers: Record = bearerHeaders(apiKey); + if (sessionId) headers['X-Session-Id'] = sessionId; + + const searchParams = new URLSearchParams({ + query, + path, + limit: String(limit), + type, + show_hidden: String(showHidden) + }); + const base = baseUrl.replace(/\/$/, ''); + const searchRes = await fetch(`${base}/files/search?${searchParams.toString()}`, { + headers + }).catch(() => null); + + if (searchRes?.ok) { + const json = await searchRes.json().catch(() => null); + if (Array.isArray(json?.results)) return { results: json.results }; + } + + const globParams = new URLSearchParams({ + pattern: query.trim() ? `*${query.trim()}*` : '*', + path, + type, + max_results: String(limit) + }); + const globRes = await fetch(`${base}/files/glob?${globParams.toString()}`, { + headers + }).catch((err) => { + console.error('open-terminal searchFiles error:', err); + return null; + }); + if (!globRes?.ok) return null; + + const json = await globRes.json().catch(() => null); + const root = json?.path ?? path; + return { + results: (json?.matches ?? []) + .filter((item: FileEntry & { path: string }) => showHidden || !hasHiddenPathPart(item.path)) + .map((item: FileEntry & { path: string }) => ({ + path: joinTerminalPath(root, item.path), + name: basename(item.path), + type: item.type, + size: item.size, + modified: item.modified + })) + }; +}; + export const readFile = async ( baseUrl: string, apiKey: string, diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 32f552ba3a..cee85324ed 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1359,6 +1359,26 @@ status: 'processed' } ]; + } else if (type === 'filesystem') { + const path = data.path ?? data.url ?? data.id; + if ( + !path || + files.find((f) => f.type === 'filesystem' && (f.path ?? f.url ?? f.id) === path) + ) { + return; + } + files = [ + ...files, + { + type: 'filesystem', + id: path, + path, + url: path, + name: data.name, + size: data.size, + status: 'processed' + } + ]; } else { if (files.find((f) => f.url === data || f.name === data)) { return; diff --git a/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte b/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte index deea60f895..8ecf6f4e2e 100644 --- a/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte +++ b/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte @@ -88,8 +88,8 @@
0 ? '' : 'hidden'} id="suggestions-container"> - -
+ +
{#if char === '/'} ; let folderItems: any[] = []; + let filesystemItems: any[] = []; let knowledgeItems: any[] = []; let fileItems: any[] = []; let modelItems: any[] = []; @@ -48,6 +58,7 @@ $: filteredModels = query ? fuse.search(query).map((e) => e.item) : modelItems; $: knowledgeResults = [ + ...filesystemItems, ...(query.startsWith('http') ? isYoutubeUrl(query) ? [{ type: 'youtube', name: query, description: query }] @@ -81,11 +92,68 @@ }); const getItems = () => { + const terminal = getSelectedTerminal(); + getFilesystemItems(terminal); getFolderItems(); getKnowledgeItems(); getKnowledgeFileItems(); }; + const getFilesystemItems = async (terminal = getSelectedTerminal()) => { + if (!terminal) { + filesystemItems = []; + return; + } + + const res = await searchFiles( + terminal.url, + terminal.key, + query, + '.', + 20, + 'any', + $activeChatId || undefined, + localStorage.getItem('fileNav:showHidden') === 'true' + ).catch(() => null); + + filesystemItems = (res?.results ?? []).map((item: any) => ({ + ...item, + type: 'filesystem', + filesystem_type: item.type, + id: item.path, + url: item.path, + name: item.name, + description: item.path, + status: 'processed' + })); + }; + + const chatContext = (terminal: any) => terminal?.contexts?.chat ?? {}; + + const getSelectedTerminal = (): { url: string; key: string } | null => { + if (!$selectedTerminalId) return null; + + const systemTerminal = ($terminalServers ?? []).find( + (t) => t.id && t.id === $selectedTerminalId + ); + const systemChatContext = chatContext(systemTerminal); + if (systemTerminal) { + if ( + systemChatContext === false || + (isTemporaryChatId($activeChatId) && systemChatContext?.context_id === 'chat_id') + ) { + return null; + } + + return { url: systemTerminal.url, key: localStorage.token }; + } + + const directTerminal = ($settings?.terminalServers ?? []).find( + (t: any) => t.url === $selectedTerminalId && t.enabled + ); + return directTerminal?.url ? { url: directTerminal.url, key: directTerminal.key ?? '' } : null; + }; + const getFolderItems = () => { folderItems = (($folders ?? []) as any[]) .map((folder) => ({ @@ -122,6 +190,11 @@ }; const selectKnowledgeItem = (item: any) => { + if (item.type === 'filesystem') { + onSelect({ type: 'filesystem', data: item }); + return; + } + if (['youtube', 'web'].includes(item.type)) { if (isValidHttpUrl(query)) { onSelect({ type: 'web', data: query }); @@ -175,12 +248,14 @@ {$i18n.t('Collections')} {:else if item?.type === 'file'} {$i18n.t('Files')} + {:else if item?.type === 'filesystem'} + {$i18n.t('Filesystem')} {/if}
{/if}