mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-19 04:53:52 +00:00
refac
This commit is contained in:
parent
5d4f16bcc9
commit
af34515131
14 changed files with 804 additions and 98 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -18,4 +18,5 @@ export enum TTS_RESPONSE_SPLIT {
|
|||
export type TerminalContext = {
|
||||
terminalId: string | null;
|
||||
currentDirectory: string;
|
||||
file?: import('$lib/apis/terminal').FilesystemFile | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue