This commit is contained in:
Timothy Jaeryang Baek 2026-10-16 04:07:18 +04:00
parent 5d4f16bcc9
commit af34515131
14 changed files with 804 additions and 98 deletions

View file

@ -1,6 +1,36 @@
import { get } from 'svelte/store';
import { config } from '$lib/stores';
import { checkSourceSize, readSourceResponse } from './sourceText';
// The same attachment is used by @mentions, terminal uploads, and Files-pane requests.
export type FilesystemFile = {
type: 'filesystem';
id: string;
path: string;
url: string;
name: string;
status: string;
size?: number;
modified?: number;
terminal_id?: string;
session_id?: string | null;
};
export const filesystemFile = (
entry: { path: string; name: string; size?: number; modified?: number },
terminalId: string,
sessionId: string | null
): FilesystemFile => ({
type: 'filesystem',
id: entry.path,
path: entry.path,
url: entry.path,
name: entry.name,
size: entry.size,
modified: entry.modified,
status: 'processed',
terminal_id: terminalId,
session_id: sessionId
});
export type FileEntry = {
name: string;
type: 'file' | 'directory';

View file

@ -95,6 +95,7 @@
updateChatById,
updateChatFolderIdById
} from '$lib/apis/chats';
import type { FilesystemFile } from '$lib/apis/terminal';
import { generateOpenAIChatCompletion, getErrorMessage } from '$lib/apis/openai';
import { processUrl, processWebSearch } from '$lib/apis/retrieval';
import {
@ -475,6 +476,20 @@
let chatFiles = [];
let files: any[] = [];
let terminalContext: TerminalContext | null = null;
const askFile = async (instruction: string, file: FilesystemFile) => {
const attachments = [structuredClone(file)];
const last = history.currentId ? history.messages[history.currentId] : null;
if (last?.role === 'assistant' && !last.done) {
chatRequestQueues.update((queues) => ({
...queues,
[$chatId]: [
...(queues[$chatId] ?? []),
{ id: uuidv4(), prompt: instruction, files: attachments }
]
}));
} else await submitPrompt(instruction, attachments);
};
let params = {};
let chatVariables = {};
let showChatVariablesModal = false;
@ -1267,7 +1282,16 @@
});
};
const terminalEventHandler = (type: string, data: any) => {
const terminalEventHandler = (type: string, data: any, message: any) => {
const attachment = history.messages[message?.parentId]?.files?.find(
(file: any) => file.type === 'filesystem'
);
if (attachment) {
if (attachment.terminal_id && attachment.terminal_id !== $selectedTerminalId) return;
if (type === 'terminal:display_file' && terminalContext?.file?.path === data.path)
showFileNavDir.set(data.path);
return;
}
if (type === 'terminal:display_file') {
if (!data?.path) return;
if ($settings?.terminalFileDisplay === 'inline') return;
@ -1275,7 +1299,7 @@
} else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') {
if (!data?.path) return;
showFileNavDir.set(data.path);
} else if (type === 'terminal:run_command') {
} else if (type === 'terminal:run_command' && !terminalContext?.file) {
showFileNavDir.set('/');
}
};
@ -1760,7 +1784,7 @@
eventConfirmationInputType = data?.input?.type ?? data?.type ?? '';
eventConfirmationInputOptions = data?.input?.options ?? data?.options ?? [];
} else if (type.startsWith('terminal:')) {
terminalEventHandler(type, data);
terminalEventHandler(type, data, message);
} else {
console.log('Unknown message type', data);
}
@ -3068,10 +3092,10 @@
let processingQueueChats = new Set<string>();
const processNextInQueue = async (targetChatId: string) => {
if (processingQueueChats.has(targetChatId)) return;
if (processingQueueChats.has(targetChatId) || targetChatId !== $chatId) return;
const queue = $chatRequestQueues[targetChatId];
if (!queue || queue.length === 0) return;
if (!queue || queue.length === 0 || queue[0].error) return;
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
if (
(lastMessage && lastMessage.role === 'assistant' && !lastMessage.done) ||
@ -3083,7 +3107,11 @@
}
processingQueueChats.add(targetChatId);
const queuedMessages = [...queue];
const boundary = queue.findIndex((entry) =>
entry.files.some((file: any) => file.type === 'filesystem')
);
const queuedMessages =
boundary === 0 ? queue.slice(0, 1) : boundary < 0 ? [...queue] : queue.slice(0, boundary);
const queuedMessageIds = new Set(queuedMessages.map((m) => m.id));
try {
const combinedPrompt = queuedMessages.map((m) => m.prompt).join('\n\n');
@ -3099,7 +3127,10 @@
console.error(error);
chatRequestQueues.update((q) => ({
...q,
[targetChatId]: [...queuedMessages, ...(q[targetChatId] ?? [])]
[targetChatId]: [
...queuedMessages.map((entry) => ({ ...entry, error: String(error) })),
...(q[targetChatId] ?? [])
]
}));
} finally {
processingQueueChats.delete(targetChatId);
@ -3125,6 +3156,12 @@
await stopResponse(false);
await tick();
await submitPrompt(item.prompt, item.files);
} catch (error) {
toast.error(String(error));
chatRequestQueues.update((q) => ({
...q,
[targetChatId]: [{ ...item, error: String(error) }, ...(q[targetChatId] ?? [])]
}));
} finally {
processingQueueChats.delete(targetChatId);
// Completion can arrive before submitPrompt returns, while the queue is locked.
@ -3575,7 +3612,9 @@
saveSessionSelectedModels();
return await sendMessage(history, userMessageId, { modelId: bridgeRequest?.modelId ?? null });
return await sendMessage(history, userMessageId, {
modelId: bridgeRequest?.modelId ?? null
});
};
const handleManualCompact = async () => {
@ -3859,7 +3898,11 @@
files = [];
messageInput?.setText('');
return (await submitPrompt(userPrompt, _files, bridgeRequest)) ?? { status: 'rejected' };
return (
(await submitPrompt(userPrompt, _files, bridgeRequest)) ?? {
status: 'rejected'
}
);
};
const sendMessage = async (
@ -3877,6 +3920,21 @@
regenerationPrompt?: string | null;
} = {}
) => {
const parent = _history.messages[parentId];
if (parent?.files?.some((file: any) => file.type === 'filesystem')) {
const attached = parent.files.filter((file: any) => file.type === 'filesystem');
const terminal = attached.find((file: any) => file.terminal_id)?.terminal_id;
if (
attached.some(
(file: any) =>
(file.terminal_id && file.terminal_id !== terminal) ||
(file.session_id && file.session_id !== $chatId)
)
)
throw new Error('Attached files must belong to this chat and the same terminal.');
if (terminal && !isTerminalAvailable(terminal))
throw new Error('The attached file’s terminal is unavailable.');
}
if (autoScroll) {
scrollToBottom();
}
@ -4095,6 +4153,10 @@
) => {
const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId];
const filesystemAttachment = userMessage?.files?.find(
(file: any) => file.type === 'filesystem'
);
const requestTerminalId = filesystemAttachment?.terminal_id ?? $selectedTerminalId;
const chatMessageFiles = _messages
.filter((message) => message.files)
@ -4257,7 +4319,7 @@
: $config?.features?.enable_tools
),
skill_ids: skillIds.length > 0 ? skillIds : undefined,
terminal_id: terminalEnabled && $selectedTerminalId ? $selectedTerminalId : undefined,
terminal_id: terminalEnabled && requestTerminalId ? requestTerminalId : undefined,
tool_servers: $config?.features?.enable_tool_servers
? [
...($toolServers ?? []).filter(
@ -4266,7 +4328,10 @@
// Direct terminal servers — always included when enabled (not routed through selectedToolIds)
...(terminalEnabled
? ($terminalServers ?? [])
.filter((server) => !server.id)
.filter(
(server) =>
!server.id && (!filesystemAttachment || server.url === requestTerminalId)
)
.map((server) => ({ ...server, is_terminal: true }))
: [])
]
@ -4335,6 +4400,8 @@
return null;
});
if (filesystemAttachment && (!res || res.error))
throw new Error('File request failed. Retry from the message or queue.');
if (res) {
if (res.error) {
await handleOpenAIError(res.error, responseMessage);
@ -4353,9 +4420,25 @@
const { [_chatId]: pendingQueue, ...rest } = q;
return {
...rest,
[res.chat_id]: [...pendingQueue, ...(rest[res.chat_id] ?? [])]
[res.chat_id]: [
...pendingQueue.map((entry) => ({
...entry,
files: entry.files.map((file) =>
file.type === 'filesystem' && file.session_id == null
? { ...file, session_id: res.chat_id }
: file
)
})),
...(rest[res.chat_id] ?? [])
]
};
});
for (const file of userMessage?.files ?? [])
if (file.type === 'filesystem' && file.session_id == null)
file.session_id = res.chat_id;
for (const file of history.messages[userMessage.id]?.files ?? [])
if (file.type === 'filesystem' && file.session_id == null)
file.session_id = res.chat_id;
await chatId.set(res.chat_id);
if (!$temporaryChatEnabled && !embedded) {
// Carry the picks made on the new-chat page over to this chat
@ -5401,6 +5484,7 @@
return a;
}, [])}
submitPrompt={submitHandler}
onAskFile={askFile}
{stopResponse}
{bridge}
bind:callMode

View file

@ -45,6 +45,9 @@
export let callMode = 'current';
export let eventTarget: EventTarget;
export let submitPrompt: Function;
export let onAskFile:
| ((instruction: string, file: import('$lib/apis/terminal').FilesystemFile) => Promise<void>)
| null = null;
export let stopResponse: Function;
export let showMessage: Function;
export let files;
@ -300,7 +303,7 @@
}}
/>
{:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId}
<FileNav {chatId} bind:terminalContext />
<FileNav {onAskFile} {chatId} bind:terminalContext />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav />
{:else}
@ -430,7 +433,7 @@
}}
/>
{:else if activeTab === 'files' && terminalFilesAvailable && $selectedTerminalId}
<FileNav overlay={dragged} {chatId} bind:terminalContext />
<FileNav {onAskFile} overlay={dragged} {chatId} bind:terminalContext />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav overlay={dragged} />
{:else}

View file

@ -13,6 +13,9 @@
<script lang="ts">
import type { TerminalContext } from '$lib/types';
import { filesystemFile, type FilesystemFile } from '$lib/apis/terminal';
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import ArrowRight from '$lib/components/icons/ArrowRight.svelte';
import { toast } from 'svelte-sonner';
import Plus from '$lib/components/icons/Plus.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
@ -74,6 +77,186 @@
export let overlay = false;
export let chatId: string | null = null;
export let terminalContext: TerminalContext | null = null;
export let onAskFile: ((instruction: string, file: FilesystemFile) => Promise<void>) | null =
null;
let viewerRoot: HTMLDivElement;
let fileAsk: {
text: string;
location?: string;
anchor: Range | HTMLElement;
} | null = null;
// Keep typing from invalidating the keyed selection and remounting the input.
let fileAskInput = '';
let asking = false;
const positionFileAsk = (node: HTMLFormElement, anchor: Range | HTMLElement) => {
document.body.appendChild(node);
const reference = {
getBoundingClientRect: () => anchor.getBoundingClientRect(),
contextElement: viewerRoot
};
const cleanup = autoUpdate(reference, node, () => {
computePosition(reference, node, {
strategy: 'fixed',
placement: anchor instanceof Range ? 'bottom-start' : 'bottom-end',
middleware: [offset(6), flip(), shift({ padding: 8 })]
}).then(({ x, y }) => {
node.style.left = `${x}px`;
node.style.top = `${y}px`;
});
});
return {
destroy() {
cleanup();
node.remove();
}
};
};
const submitFileAsk = async () => {
const request = fileAsk;
const instruction = fileAskInput.trim();
if (!request || !instruction || asking || !onAskFile) return;
asking = true;
try {
const file = await prepareFile();
if (fileAsk !== request) return;
const quote = [request.location, request.text]
.filter(Boolean)
.join('\n')
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
await onAskFile(
request.text || request.location ? `${quote}\n\n${instruction}` : instruction,
file
);
if (fileAsk === request) fileAsk = null;
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
asking = false;
}
};
let openedFile: FilesystemFile | null = null;
let pendingFile: FilesystemFile | null = null;
let viewerLabel = '';
let loadGeneration = 0;
let dirty = false;
let diskChanged = false;
let saveConnection: { url: string; key: string; chatId: string | null; path: string } | null =
null;
let dirtyChoice: {
sending: boolean;
resolve: (choice: 'save' | 'discard' | 'cancel') => void;
} | null = null;
const protectBuffer = async (sending = false) => {
if (!dirty && !saving) return true;
if (saving || dirtyChoice) return false;
const choice = await new Promise<'save' | 'discard' | 'cancel'>((resolve) => {
dirtyChoice = { sending, resolve };
});
dirtyChoice = null;
if (choice === 'cancel') return false;
if (choice === 'save') {
try {
await filePreviewRef.save();
} catch (error) {
toast.error(String(error));
return false;
}
if (dirty) return false;
}
if (choice === 'discard') {
filePreviewRef?.discard();
dirty = false;
}
return true;
};
const publishFile = () => {
if (!openedFile) return;
terminalContext = {
terminalId: openedFile.terminal_id!,
currentDirectory: currentPath,
file: openedFile
};
};
const captureSelection = () => {
const selection = window.getSelection();
if (!onAskFile || !openedFile || !selection?.rangeCount || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
if (!viewerRoot?.contains(range.startContainer) || !viewerRoot.contains(range.endContainer))
return;
if (
range.startContainer.parentElement?.closest(
'button, select, .excel-row-num, .excel-col-hdr, .csv-row-num'
)
)
return;
const source = filePreviewRef?.getSelection();
const text = source?.text ?? selection.toString();
if (!text.trim()) return;
if (new TextEncoder().encode(text).length > 16384) {
toast.error('Select less text (maximum 16 KiB).');
return;
}
const page =
range.startContainer.parentElement?.closest<HTMLElement>('[data-page-number]')?.dataset
.pageNumber;
const location = source?.location ?? (page ? `Page ${page}` : viewerLabel);
fileAskInput = '';
fileAsk = {
text,
location,
anchor: range.cloneRange()
};
};
const prepareFile = async (): Promise<FilesystemFile> => {
const target = openedFile;
if (!target) throw new Error('Wait for the file to load.');
if (!(await protectBuffer(true))) throw new Error('File request cancelled.');
if (
!openedFile ||
openedFile.path !== target.path ||
openedFile.terminal_id !== $selectedTerminalId ||
openedFile.session_id !== chatId
)
throw new Error('The file or terminal changed. Open it again before sending.');
if (diskChanged)
throw new Error('The file changed on disk. Reload and select the content again.');
return structuredClone(openedFile);
};
$: if (previewPort !== null && openedFile) {
openedFile = null;
if (terminalContext) terminalContext = { ...terminalContext, file: null };
}
$: {
(selectedFile, $selectedTerminalId, chatId);
fileAsk = null;
}
$: viewerLabel = selectedExcelSheet
? `Sheet ${selectedExcelSheet}`
: /\.(pdf|docx|pptx)$/i.test(selectedFile ?? '')
? `Page ${currentSlide + 1}`
: '';
const saveFile = async (content: string) => {
const target = saveConnection;
if (!target || !selectedFileWritable) throw new Error('File is not writable.');
const name = target.path.split('/').pop()!;
const dir = target.path.slice(0, target.path.lastIndexOf('/') + 1) || '/';
const result = await uploadToTerminal(
target.url,
target.key,
dir,
new File([content], name, { type: 'text/plain' }),
target.chatId ?? undefined
);
if (!result) throw new Error('Failed to save file. Your changes are still in the editor.');
if (saveConnection === target) {
fileContent = content;
diskChanged = false;
}
await tick();
};
// ── Terminal panel state ────────────────────────────────────────────
let terminalExpanded = false;
@ -459,10 +642,17 @@
if (mounted && terminal) {
if (chatChanged && chatId && !oldChatId) {
if (openedFile) {
openedFile = { ...openedFile, session_id: chatId };
publishFile();
}
// Chat just got created (null → real ID): persist the current
// browsed path as the new session's cwd — don't re-fetch.
setCwd(terminal.url, terminal.key, savedPath, chatId);
} else if (terminalChanged || chatChanged) {
++loadGeneration;
openedFile = null;
if (terminalContext) terminalContext = { ...terminalContext, file: null };
// Terminal switched, new chat started, or switched between
// existing chats — re-fetch the session cwd.
loading = true;
@ -768,6 +958,10 @@
path: string,
options: { preserveTree?: boolean; restoreTree?: boolean } = {}
) => {
if (!(await protectBuffer())) return;
const generation = ++loadGeneration;
const selector = $selectedTerminalId,
session = chatId;
const terminal = selectedTerminal;
if (!terminal) return;
const directory = clampToFileRoot(path);
@ -777,6 +971,9 @@
resetTreeState();
}
openedFile = null;
saveConnection = null;
dirty = false;
loading = true;
error = null;
selectedFile = null;
@ -790,6 +987,8 @@
pushNavHistory(directory);
const result = await listFiles(terminal.url, terminal.key, directory, chatId ?? undefined);
if (generation !== loadGeneration || selector !== $selectedTerminalId || session !== chatId)
return;
loading = false;
// Set working directory on the terminal server (fire-and-forget)
@ -859,7 +1058,10 @@
}
};
const openEntry = async (entry: FileEntry, options: { page?: unknown } = {}) => {
const openEntry = async (
entry: FileEntry,
options: { page?: unknown; refresh?: boolean } = {}
) => {
const fullPath =
'fullPath' in entry ? (entry as BrowserRow).fullPath : entryPath(currentPath, entry);
const parentPath = 'parentPath' in entry ? (entry as BrowserRow).parentPath : currentPath;
@ -868,6 +1070,11 @@
return;
}
if (options.refresh && (dirty || saving)) {
diskChanged = true;
return;
}
if (!(await protectBuffer())) return;
const filePath = fullPath;
if (parentPath !== currentPath) {
await loadDir(parentPath);
@ -878,15 +1085,55 @@
const terminal = selectedTerminal;
if (!terminal) return;
const generation = ++loadGeneration;
const selector = $selectedTerminalId!,
session = chatId;
const previousView =
openedFile?.path === filePath
? { showRaw, currentSlide, selectedExcelSheet, scroll: viewerRoot?.scrollTop ?? 0 }
: null;
const current = () =>
generation === loadGeneration && selector === $selectedTerminalId && session === chatId;
openedFile = null;
if (terminalContext) terminalContext = { ...terminalContext, file: null };
dirty = false;
diskChanged = false;
saveConnection = { ...terminal, path: filePath, chatId: session };
pendingFile = filesystemFile(
{ path: filePath, name: entry.name, size: entry.size, modified: entry.modified },
selector,
session
);
selectedFile = filePath;
fileLoading = true;
clearFilePreview();
documentTargetPage = normalizeDocumentTargetPage(options.page);
documentTargetPage = normalizeDocumentTargetPage(
options.page ?? (previousView ? previousView.currentSlide + 1 : null)
);
if (supportsComments(filePath)) {
if (previousView) {
currentSlide = previousView.currentSlide;
selectedExcelSheet = previousView.selectedExcelSheet;
}
fileLoading = false;
return;
}
const next = {
fileImageUrl: null,
fileVideoUrl: null,
fileAudioUrl: null,
filePdfData: null,
fileSqliteData: null,
fileDocxData: null,
fileOfficeHtml: null,
fileOfficeSlides: null,
excelWorkbook: null,
excelSheetNames: [],
selectedExcelSheet: '',
currentSlide: 0,
fileContent: null
} as any;
if (isImage(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -894,7 +1141,7 @@
filePath,
chatId ?? undefined
);
if (result) fileImageUrl = URL.createObjectURL(result.blob);
if (result) next.fileImageUrl = URL.createObjectURL(result.blob);
} else if (isVideo(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -902,7 +1149,7 @@
filePath,
chatId ?? undefined
);
if (result) fileVideoUrl = URL.createObjectURL(result.blob);
if (result) next.fileVideoUrl = URL.createObjectURL(result.blob);
} else if (isAudio(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -910,7 +1157,7 @@
filePath,
chatId ?? undefined
);
if (result) fileAudioUrl = URL.createObjectURL(result.blob);
if (result) next.fileAudioUrl = URL.createObjectURL(result.blob);
} else if (isPdf(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -918,7 +1165,7 @@
filePath,
chatId ?? undefined
);
if (result) filePdfData = await result.blob.arrayBuffer();
if (result) next.filePdfData = await result.blob.arrayBuffer();
} else if (isSqlite(filePath)) {
const result = await downloadFileBlob(
terminal.url,
@ -926,7 +1173,7 @@
filePath,
chatId ?? undefined
);
if (result) fileSqliteData = await result.blob.arrayBuffer();
if (result) next.fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
const ext = getFileExt(filePath);
try {
@ -938,7 +1185,7 @@
chatId ?? undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
next.filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await downloadFileBlob(
terminal.url,
@ -948,7 +1195,7 @@
);
if (!result) throw new Error('Preview failed');
const arrayBuffer = await result.blob.arrayBuffer();
fileDocxData = arrayBuffer;
next.fileDocxData = arrayBuffer;
}
} else if (ext === 'xlsx') {
const result = await downloadFileBlob(
@ -961,14 +1208,14 @@
const arrayBuffer = await result.blob.arrayBuffer();
const XLSX = await import('xlsx');
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
excelWorkbook = wb;
excelSheetNames = wb.SheetNames;
if (excelSheetNames.length > 0) {
selectedExcelSheet = excelSheetNames[0];
next.excelWorkbook = wb;
next.excelSheetNames = wb.SheetNames;
if (next.excelSheetNames.length > 0) {
next.selectedExcelSheet = next.excelSheetNames[0];
const { excelToTable } = await import('$lib/utils/excelToTable');
const result = await excelToTable(wb.Sheets[selectedExcelSheet]);
const result = await excelToTable(wb.Sheets[next.selectedExcelSheet]);
const DOMPurify = (await import('dompurify')).default;
fileOfficeHtml = DOMPurify.sanitize(result.html);
next.fileOfficeHtml = DOMPurify.sanitize(result.html);
}
}
} else if (ext === 'pptx') {
@ -979,7 +1226,7 @@
chatId ?? undefined
);
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
next.filePdfData = await preview.blob.arrayBuffer();
} else {
const result = await downloadFileBlob(
terminal.url,
@ -991,20 +1238,62 @@
const arrayBuffer = await result.blob.arrayBuffer();
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const fallback = await pptxToImages(arrayBuffer);
fileOfficeSlides = fallback.images;
currentSlide = 0;
next.fileOfficeSlides = fallback.images;
next.currentSlide = 0;
}
}
} catch (e) {
console.error('Failed to render Office file:', e);
fileContent = $i18n.t('Error previewing file: {{error}}', {
next.fileContent = $i18n.t('Error previewing file: {{error}}', {
error: e instanceof Error ? e.message : $i18n.t('Unknown error')
});
}
} else {
fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined);
next.fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined);
}
if (!current()) {
[next.fileImageUrl, next.fileVideoUrl, next.fileAudioUrl]
.filter(Boolean)
.forEach((url) => URL.revokeObjectURL(url));
return;
}
fileImageUrl = next.fileImageUrl;
fileVideoUrl = next.fileVideoUrl;
fileAudioUrl = next.fileAudioUrl;
filePdfData = next.filePdfData;
fileSqliteData = next.fileSqliteData;
fileDocxData = next.fileDocxData;
fileOfficeHtml = next.fileOfficeHtml;
fileOfficeSlides = next.fileOfficeSlides;
excelWorkbook = next.excelWorkbook;
excelSheetNames = next.excelSheetNames;
selectedExcelSheet = next.selectedExcelSheet;
currentSlide = next.currentSlide;
fileContent = next.fileContent;
fileLoading = false;
if (
next.fileContent !== null ||
[
next.fileImageUrl,
next.fileVideoUrl,
next.fileAudioUrl,
next.filePdfData,
next.fileSqliteData,
next.fileDocxData,
next.fileOfficeHtml,
next.fileOfficeSlides
].some(Boolean)
) {
openedFile = pendingFile;
publishFile();
}
if (previousView) {
showRaw = previousView.showRaw;
currentSlide = previousView.currentSlide;
await tick();
if (viewerRoot) viewerRoot.scrollTop = previousView.scroll;
}
};
const openFileMatch = async (match: TerminalFileMatch) => {
@ -1517,7 +1806,12 @@
const fileName = filePath.substring(lastSlash + 1);
// Always reload directory to ensure entries are fresh
if (!(await protectBuffer())) return;
const selector = $selectedTerminalId,
session = chatId;
await loadDir(dir);
if (selector !== $selectedTerminalId || session !== chatId || currentPath !== dir || dirty)
return;
await tick();
const entry = entries.find((e) => e.name === fileName);
@ -1543,9 +1837,9 @@
if (comparison) return;
if (selectedFile) {
if (selectedFile === filePath || currentPath.startsWith(dir)) {
if (selectedFile === filePath) {
const fileName = selectedFile.split('/').pop() ?? '';
await openEntry({ name: fileName, type: 'file', size: 0 });
await openEntry({ name: fileName, type: 'file', size: 0 }, { refresh: true });
}
} else {
if (currentPath.startsWith(dir) || dir.startsWith(currentPath)) {
@ -1612,6 +1906,8 @@
});
onDestroy(() => {
++loadGeneration;
dirtyChoice?.resolve('cancel');
clearMatchRequest();
if (fileImageUrl) URL.revokeObjectURL(fileImageUrl);
if (fileVideoUrl) URL.revokeObjectURL(fileVideoUrl);
@ -1633,7 +1929,19 @@
}}
/>
<svelte:window on:keydown={handleKeydown} on:click={handleWindowClick} />
<svelte:window
on:keydown={(event) => {
if (event.key === 'Escape' && fileAsk) {
fileAsk = null;
event.preventDefault();
} else handleKeydown(event);
}}
on:click={handleWindowClick}
on:pointerdown={(event) => {
if (fileAsk && !dirtyChoice && !(event.target as Element)?.closest('[data-file-ask]'))
fileAsk = null;
}}
/>
{#if comparison}
{#key comparison}
@ -1733,7 +2041,8 @@
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={() => {
on:click={async () => {
if (!(await protectBuffer())) return;
if (editing) filePreviewRef?.cancelEdit();
showRaw = !showRaw;
}}
@ -1752,7 +2061,8 @@
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
on:click={() =>
filePreviewRef?.saveCodeFile().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
@ -1769,7 +2079,8 @@
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
on:click={() =>
filePreviewRef?.saveCodeFile().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
@ -1786,7 +2097,8 @@
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
on:click={() =>
filePreviewRef?.saveCodeFile().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
@ -1810,7 +2122,8 @@
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveEdit()}
on:click={() =>
filePreviewRef?.saveEdit().catch((error) => toast.error(String(error)))}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
@ -1924,8 +2237,26 @@
{/if}
{/if}
{#if diskChanged}<div
role="status"
class="flex flex-wrap items-center gap-2 px-2 py-1.5 text-xs text-gray-500 dark:text-gray-400"
>
File updated on disk. Your local changes are preserved. <button
class="underline"
on:click={() => openEntry({ name: selectedFile!.split('/').pop()!, type: 'file' })}
>Reload</button
>
<button
class="underline"
on:click={() => filePreviewRef.save().catch((e) => toast.error(String(e)))}
>Save local changes</button
>
</div>{/if}
<!-- Content -->
<div
bind:this={viewerRoot}
on:mouseup={captureSelection}
on:keyup={captureSelection}
class="flex-1 overflow-y-auto min-h-0 min-w-0"
on:click={(e) => {
closeDirectoryMenu();
@ -1956,6 +2287,13 @@
apiKey={selectedTerminal.key}
{chatId}
targetPage={documentTargetPage}
initialSheet={selectedExcelSheet}
onReady={() => {
openedFile = pendingFile;
publishFile();
}}
bind:currentSlide
bind:sheet={selectedExcelSheet}
/>
{/key}
{:else}
@ -1964,6 +2302,10 @@
bind:editing
bind:showRaw
bind:saving
bind:dirty
{chatId}
resourceVersion={commentGeneration}
onLocalLink={(path) => showFileNavPath.set({ path })}
bind:currentSlide
{selectedFile}
readOnly={!selectedFileWritable}
@ -1992,24 +2334,7 @@
baseUrl={previewBaseUrl}
apiKey={selectedTerminal?.key ?? ''}
overlay={overlay || isDraggingHandle}
onSave={async (content) => {
const terminal = selectedTerminal;
if (!terminal || !selectedFile || !selectedFileWritable) return;
const fileName = selectedFile.split('/').pop() ?? 'file';
const dir = selectedFile.substring(0, selectedFile.lastIndexOf('/') + 1) || '/';
const file = new File([content], fileName, { type: 'text/plain' });
const result = await uploadToTerminal(
terminal.url,
terminal.key,
dir,
file,
chatId ?? undefined
);
toast[result ? 'success' : 'error'](
result ? $i18n.t('File saved') : $i18n.t('Failed to save file')
);
if (result) fileContent = content;
}}
onSave={saveFile}
/>
{/if}
{:else}
@ -2388,3 +2713,69 @@
{/if}
</div>
{/if}
{#if fileAsk}
{#key fileAsk.anchor}
<form
use:positionFileAsk={fileAsk.anchor}
data-file-ask
class="fixed z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-2xl bg-white pt-3 pl-3 pr-2 pb-2 shadow-[0_16px_40px_-28px_rgba(0,0,0,0.55)] ring-1 ring-black/5 dark:bg-gray-850 dark:text-white dark:ring-white/10"
aria-label={$i18n.t('Ask about this file')}
on:submit|preventDefault={submitFileAsk}
>
{#if fileAsk.text}
<blockquote
class="mb-2 border-l-2 border-gray-200 pl-2 text-xs leading-4 text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
<span class="line-clamp-3 whitespace-pre-wrap break-words">{fileAsk.text}</span>
</blockquote>
{/if}
<div class="flex items-center gap-1.5">
<textarea
id="file-ask-input"
rows="1"
class="min-w-0 flex-1 resize-none border-0 bg-transparent p-0 text-sm leading-5 text-gray-700 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-gray-300 dark:placeholder:text-gray-500"
placeholder={$i18n.t('Ask or edit…')}
aria-label={$i18n.t('Ask or edit…')}
bind:value={fileAskInput}
on:input={(event) => {
event.currentTarget.style.height = 'auto';
event.currentTarget.style.height = `${Math.min(96, event.currentTarget.scrollHeight)}px`;
}}
on:keydown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submitFileAsk();
}
}}
/>
<button
type="submit"
disabled={asking || !fileAskInput.trim()}
aria-label={$i18n.t('Send')}
class="flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-full transition-colors {fileAskInput.trim()
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100'
: 'bg-gray-200 text-white dark:bg-gray-700 dark:text-gray-900'}"
>
{#if asking}<Spinner className="size-3.5" />{:else}<ArrowRight
className="size-3.5 -rotate-90"
/>{/if}
</button>
</div>
</form>
{/key}
{/if}
<ConfirmDialog
show={dirtyChoice !== null}
title={$i18n.t('Unsaved changes')}
confirmLabel={dirtyChoice?.sending ? $i18n.t('Save and send') : $i18n.t('Save')}
on:confirm={() => dirtyChoice?.resolve('save')}
on:cancel={() => dirtyChoice?.resolve('cancel')}
>
<p class="text-sm text-gray-500">{$i18n.t('Save your changes before continuing?')}</p>
{#if dirtyChoice && !dirtyChoice.sending}<button
type="button"
class="mt-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
on:click={() => dirtyChoice?.resolve('discard')}>{$i18n.t('Discard changes')}</button
>{/if}
</ConfirmDialog>

View file

@ -1,6 +1,18 @@
<script lang="ts">
import { getContext, onMount, onDestroy } from 'svelte';
import type { WorkBook } from 'xlsx';
export let onReady = () => {};
export let currentSlide = 0;
let reportedReady = false;
export let initialSheet = '';
const reportReady = () => {
if (!loading && !previewError && !reportedReady) {
reportedReady = true;
onReady();
}
};
$: (loading, previewError, reportReady());
import { downloadFileBlob, downloadFilePreview } from '$lib/apis/terminal';
import { readOfficeComments } from '$lib/utils/comments/office';
import {
@ -36,7 +48,7 @@
let previewError = false;
let slides: string[] = [];
let workbook: WorkBook | null = null;
let sheet = '';
export let sheet = '';
let table: string | null = null;
let root: HTMLDivElement;
let popover: HTMLDivElement;
@ -162,7 +174,7 @@
const XLSX = await import('xlsx');
if (!alive) return;
workbook = XLSX.read(bytes, { type: 'array' });
await setSheet(workbook.SheetNames[0]);
await setSheet(workbook.Sheets[initialSheet] ? initialSheet : workbook.SheetNames[0]);
loading = false;
}
void readOfficeComments(bytes, ext, workbook ?? undefined)
@ -235,6 +247,7 @@
{#if loading}<div class="flex h-full items-center justify-center"><Spinner /></div>
{:else if pdf}
<PdfPagesPreview
bind:currentSlide
data={pdf}
{targetPage}
singlePage={ext === 'pptx'}
@ -253,7 +266,7 @@
onAnchors={receiveAnchors}
/>
{:else if ext === 'pptx' && slides.length}
<PptxPreview {slides} {targetPage} className="h-full" {comments} />
<PptxPreview bind:currentSlide {slides} {targetPage} className="h-full" {comments} />
{:else if table !== null}
<FilePreview
selectedFile={path}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import '$lib/utils/codemirror';
import { toast } from 'svelte-sonner';
import { basicSetup, EditorView } from 'codemirror';
import { keymap, placeholder as editorPlaceholder } from '@codemirror/view';
import { Compartment, EditorState } from '@codemirror/state';
@ -30,6 +31,15 @@
let internalValue = '';
let lastSearchTargetRequestId = 0;
export const getSelection = () => {
if (!editor || editor.state.selection.main.empty) return undefined;
const { from, to } = editor.state.selection.main;
return {
text: editor.state.sliceDoc(from, to),
location: `Lines ${editor.state.doc.lineAt(from).number}–${editor.state.doc.lineAt(to).number}`
};
};
/** Return the current editor content */
export const getValue = (): string => {
return editor?.state.doc.toString() ?? value;
@ -112,7 +122,7 @@
key: 'Mod-s',
run: () => {
if (onSave && !readOnly) {
onSave(editor?.state.doc.toString() ?? '');
void onSave(editor?.state.doc.toString() ?? '').catch((e) => toast.error(String(e)));
}
return true;
}

View file

@ -33,6 +33,28 @@
// Terminal connection for notebook execution
export let baseUrl: string = '';
export let apiKey: string = '';
export let chatId: string | null = null;
export let resourceVersion = 0;
export let onLocalLink: (path: string) => void = () => {};
export let dirty = false;
let codeValue = '';
let lastLoaded: string | null = null;
let bufferPath: string | null = null;
const syncContent = (content: string | null, path: string | null) => {
if (bufferPath !== path || codeValue === (lastLoaded ?? '')) codeValue = content ?? '';
bufferPath = path;
lastLoaded = content;
};
$: syncContent(fileContent, selectedFile);
$: dirty = editing ? editContent !== (fileContent ?? '') : codeValue !== (fileContent ?? '');
export const getSelection = () =>
editing && editTextarea?.selectionStart !== editTextarea?.selectionEnd
? {
text: editContent.slice(editTextarea.selectionStart, editTextarea.selectionEnd),
location: ''
}
: fileCodeEditorRef?.getSelection();
export const save = () => (editing ? saveEdit() : saveCodeFile());
// Office preview props
export let fileOfficeHtml: string | null = null;
@ -67,6 +89,7 @@
editing = false;
editContent = '';
saving = false;
codeValue = fileContent ?? '';
};
export const startEdit = async () => {
@ -81,11 +104,22 @@
export const saveEdit = async () => {
if (!onSave || readOnly) return;
saving = true;
await onSave(editContent);
saving = false;
editing = false;
const target = selectedFile,
content = editContent;
try {
await onSave(content);
if (selectedFile === target && editContent === content) editing = false;
} finally {
saving = false;
}
};
export const discard = () => {
codeValue = fileContent ?? '';
editContent = fileContent ?? '';
fileCodeEditorRef?.setValue(codeValue);
dirty = false;
};
export const cancelEdit = () => {
editing = false;
editContent = '';
@ -95,9 +129,12 @@
export const saveCodeFile = async () => {
if (!onSave || readOnly) return;
saving = true;
const content = fileCodeEditorRef?.getValue() ?? '';
await onSave(content);
saving = false;
const content = fileCodeEditorRef?.getValue() ?? codeValue;
try {
await onSave(content);
} finally {
saving = false;
}
};
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
@ -334,9 +371,9 @@
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
bind:value={codeValue}
filePath={selectedFile}
onSave={readOnly ? null : onSave}
onSave={readOnly ? null : saveCodeFile}
{searchTarget}
/>
</div>
@ -377,23 +414,32 @@
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
bind:value={codeValue}
filePath={selectedFile}
onSave={readOnly ? null : onSave}
onSave={readOnly ? null : saveCodeFile}
/>
</div>
{:else if isMarkdown && !showRaw}
{#key selectedFile}
<MarkdownDocument content={fileContent} id={`file-${selectedFile}`} />
<MarkdownDocument
content={fileContent}
id={`file-${selectedFile}`}
path={selectedFile ?? ''}
{baseUrl}
{apiKey}
{chatId}
{resourceVersion}
{onLocalLink}
/>
{/key}
{:else if isMarkdown && showRaw}
<div class="absolute inset-0">
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
bind:value={codeValue}
filePath={selectedFile}
onSave={readOnly ? null : onSave}
onSave={readOnly ? null : saveCodeFile}
/>
</div>
{:else if isCsv && !showRaw && csvRows.length > 0}
@ -454,9 +500,9 @@
<FileCodeEditor
{readOnly}
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
bind:value={codeValue}
filePath={selectedFile}
onSave={readOnly ? null : onSave}
onSave={readOnly ? null : saveCodeFile}
/>
</div>
{:else if isSvg && highlightedHtml && !showRaw}

View file

@ -16,12 +16,82 @@
</script>
<script lang="ts">
import { setContext } from 'svelte';
import { setContext, onDestroy } from 'svelte';
import { downloadFileBlob } from '$lib/apis/terminal';
import MarkdownTokens from '../Messages/Markdown/MarkdownTokens.svelte';
import type { Token } from 'marked';
export let content: string;
export let id: string;
export let path = '';
export let baseUrl = '';
export let apiKey = '';
export let chatId: string | null = null;
export let resourceVersion = 0;
export let onLocalLink: (path: string) => void = () => {};
/** Local document resources never become arbitrary network requests. */
function resolveDocumentPath(documentPath: string, reference: string): string | null {
if (!reference || reference.startsWith('#') || reference.startsWith('//')) return null;
if (/^[a-z][a-z\d+.-]*:/i.test(reference) && !/^[a-z]:[\\/]/i.test(reference)) return null;
let path: string;
try {
path = decodeURIComponent(reference.split(/[?#]/)[0]).replace(/\\/g, '/');
} catch {
return null;
}
if (/[\x00-\x1f]/.test(path)) return null;
const base = documentPath.replace(/\\/g, '/');
if (!path.startsWith('/') && !/^[a-z]:\//i.test(path))
path = base.slice(0, base.lastIndexOf('/') + 1) + path;
const prefix = /^[a-z]:\//i.test(path) ? path.slice(0, 3) : '/';
const parts: string[] = [];
for (const part of path.slice(prefix.length).split('/')) {
if (part === '..') parts.pop();
else if (part && part !== '.') parts.push(part);
}
return prefix + parts.join('/');
}
let generation = 0;
let urls = new Map<string, Promise<string | null>>();
let objectUrls: string[] = [];
const release = () => {
generation++;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
objectUrls = [];
urls.clear();
};
onDestroy(release);
const resolveImage = (reference: string): Promise<string | null> => {
const file = resolveDocumentPath(path, reference);
if (!file || !baseUrl) return Promise.resolve(null);
if (!urls.has(file)) {
const current = generation;
urls.set(
file,
downloadFileBlob(baseUrl, apiKey, file, chatId ?? undefined).then((result) => {
if (!result || current !== generation) return null;
const url = URL.createObjectURL(result.blob);
objectUrls.push(url);
return url;
})
);
}
return urls.get(file)!;
};
const resources = {
resolveImage,
localPath: (ref: string) => (baseUrl ? resolveDocumentPath(path, ref) : null),
open: (ref: string) => {
const file = resolveDocumentPath(path, ref);
if (file) onLocalLink(file);
}
};
setContext('terminal-document', resources);
$: {
(resourceVersion, path, baseUrl, chatId);
release();
}
setContext('markdown', {
document: true,
@ -31,11 +101,52 @@
documentMarkdown.Parser.parseInline(tokens, documentMarkdown.defaults)
});
$: tokens = documentMarkdown.lexer(content);
const resolveHtmlImages = (node: HTMLElement) => {
let alive = true;
const update = () => {
for (const img of node.querySelectorAll<HTMLImageElement>(
'img:not([data-terminal-resolved])'
)) {
const src = img.getAttribute('src') ?? '';
if (!resources.localPath(src)) continue;
img.dataset.terminalResolved = 'true';
img.removeAttribute('src');
void resolveImage(src).then((url) => {
if (alive && url) img.src = url;
});
}
};
const observer = new MutationObserver(update);
observer.observe(node, { childList: true, subtree: true });
update();
return {
destroy: () => {
alive = false;
observer.disconnect();
}
};
};
const openLink = (event: MouseEvent) => {
const anchor = (event.target as Element)?.closest('a');
const href = anchor?.getAttribute('href');
if (href && resources.localPath(href)) {
event.preventDefault();
event.stopPropagation();
resources.open(href);
}
};
</script>
<div class="document-markdown prose prose-sm dark:prose-invert min-w-0 max-w-none p-3">
<MarkdownTokens {id} {tokens} editCodeBlock={false} />
</div>
{#key resourceVersion}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
use:resolveHtmlImages
on:click|capture={openLink}
class="document-markdown prose prose-sm dark:prose-invert min-w-0 max-w-none p-3"
>
<MarkdownTokens {id} {tokens} editCodeBlock={false} />
</div>
{/key}
<style>
.document-markdown {

View file

@ -228,7 +228,7 @@
let showTerminalMenu = false;
export let messageQueue: { id: string; prompt: string; files: any[] }[] = [];
export let messageQueue: { id: string; prompt: string; files: any[]; error?: string }[] = [];
export let onQueueSendNow: (id: string) => void = () => {};
export let onQueueEdit: (id: string) => void = () => {};
export let onQueueDelete: (id: string) => void = () => {};
@ -1014,6 +1014,8 @@
files = [...files, fileItem];
if (filesystemUploadTerminal) {
fileItem.terminal_id = $selectedTerminalId;
fileItem.session_id = chatId || null;
try {
const cwd =
(terminalContext?.terminalId === $selectedTerminalId
@ -1023,7 +1025,7 @@
await getCwd(
filesystemUploadTerminal.url,
filesystemUploadTerminal.key,
chatId || undefined
fileItem.session_id || undefined
)
)?.cwd;
if (!cwd) throw new Error($i18n.t('Could not determine the upload folder.'));
@ -1032,7 +1034,7 @@
filesystemUploadTerminal.key,
cwd,
file,
chatId || undefined
fileItem.session_id || undefined
);
if (uploadedFile) {
@ -1470,6 +1472,7 @@
files = [
...files,
{
...data,
type: 'filesystem',
id: path,
path,
@ -1912,6 +1915,7 @@
<QueuedMessageItem
id={queuedMessage.id}
content={queuedMessage.prompt}
error={queuedMessage.error ?? ''}
files={queuedMessage.files}
onSendNow={onQueueSendNow}
onEdit={onQueueEdit}

View file

@ -13,7 +13,7 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getFolders } from '$lib/apis/folders';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
import { searchFiles } from '$lib/apis/terminal';
import { searchFiles, filesystemFile } from '$lib/apis/terminal';
import { decodeString, isValidHttpUrl, isYoutubeUrl } from '$lib/utils';
import {
resolveLocalizedModelDescription,
@ -107,6 +107,8 @@
return;
}
const terminalId = $selectedTerminalId;
const sessionId = $activeChatId;
const res = await searchFiles(
terminal.url,
terminal.key,
@ -114,19 +116,15 @@
'.',
20,
'any',
$activeChatId || undefined,
sessionId || undefined,
localStorage.getItem('fileNav:showHidden') === 'true'
).catch(() => null);
if (terminalId !== $selectedTerminalId || sessionId !== $activeChatId) return;
filesystemItems = (res?.results ?? []).map((item: any) => ({
...item,
type: 'filesystem',
...filesystemFile(item, terminalId!, sessionId || null),
filesystem_type: item.type,
id: item.path,
url: item.path,
name: item.name,
description: item.path,
status: 'processed'
description: item.path
}));
};

View file

@ -7,6 +7,13 @@
import { goto } from '$app/navigation';
const i18n = getContext('i18n');
const terminalDocument = getContext<
| {
localPath: (ref: string) => string | null;
resolveImage: (ref: string) => Promise<string | null>;
}
| undefined
>('terminal-document');
const markdown = getContext<
| { allowRemoteContent: boolean; document?: boolean; parseInline?: (tokens: Token[]) => string }
| undefined
@ -124,7 +131,15 @@
>
{/if}
{:else if token.type === 'image'}
<Image src={token.href} alt={token.text} allowExternal={true} />
{#if terminalDocument?.localPath(token.href)}
{#await terminalDocument.resolveImage(token.href)}
<span class="text-xs text-gray-400">{token.text}</span>
{:then url}
{#if url}<img src={url} alt={token.text} data-terminal-resolved="true" />{:else}<span
class="text-xs text-gray-400">Image unavailable: {token.text}</span
>{/if}
{/await}
{:else}<Image src={token.href} alt={token.text} allowExternal={true} />{/if}
{:else if token.type === 'strong'}
<strong
><svelte:self id={`${id}-strong`} tokens={token.tokens} {sourceIds} {onSourceClick} /></strong

View file

@ -130,7 +130,7 @@ export const visiblePinnedModels = derived([pinnedModels, models], ([$pinnedMode
export const audioQueue = writable<AudioQueue | null>(null);
export const chatRequestQueues: Writable<
Record<string, { id: string; prompt: string; files: any[] }[]>
Record<string, { id: string; prompt: string; files: any[]; error?: string }[]>
> = writable({});
export type ChannelQueuedMessage = {

View file

@ -18,4 +18,5 @@ export enum TTS_RESPONSE_SPLIT {
export type TerminalContext = {
terminalId: string | null;
currentDirectory: string;
file?: import('$lib/apis/terminal').FilesystemFile | null;
};

View file

@ -554,7 +554,7 @@
: terminalFileResult(result, params, toolServer.url, chatId)
: res;
if (data?.name === 'display_file' && params?.path && !inlineDisplayFile) {
if (data?.name === 'display_file' && params?.path && !inlineDisplayFile && !chatId) {
if (result?.exists !== false) {
displayFileHandler(
result?.path ?? params.path,