This commit is contained in:
Timothy Jaeryang Baek
2026-03-23 23:39:52 -05:00
parent 3a4b862e81
commit 139e764b2f
45 changed files with 6251 additions and 5730 deletions
-1
View File
@@ -3972,7 +3972,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **🔗 Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality.
- Easily integrate existing LiteLLM configurations using `-v /path/to/config.yaml:/app/backend/data/litellm/config.yaml` flag.
- When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`.
@@ -25,7 +25,8 @@
}
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR',
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR',
'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium',
Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px; /* Default font size */
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -725,7 +725,9 @@
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
message={$i18n.t('Are you sure you want to delete this connection? This action cannot be undone.')}
message={$i18n.t(
'Are you sure you want to delete this connection? This action cannot be undone.'
)}
confirmLabel={$i18n.t('Delete')}
on:confirm={() => {
onDelete();
@@ -733,7 +733,9 @@
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
message={$i18n.t('Are you sure you want to delete this connection? This action cannot be undone.')}
message={$i18n.t(
'Are you sure you want to delete this connection? This action cannot be undone.'
)}
confirmLabel={$i18n.t('Delete')}
on:confirm={() => {
onDelete();
+3 -1
View File
@@ -930,7 +930,9 @@
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
message={$i18n.t('Are you sure you want to delete this connection? This action cannot be undone.')}
message={$i18n.t(
'Are you sure you want to delete this connection? This action cannot be undone.'
)}
confirmLabel={$i18n.t('Delete')}
on:confirm={() => {
onDelete();
@@ -31,7 +31,9 @@
onChange={() => onChange(value)}
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
<span
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
@@ -50,7 +50,7 @@
class="min-w-[170px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
hideHandler();
}}
@@ -102,7 +102,7 @@
</button>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
pinModelHandler(model?.id);
}}
@@ -123,7 +123,7 @@
</button>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
copyLinkHandler();
}}
@@ -135,7 +135,7 @@
{#if model?.is_active ?? true}
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneHandler();
}}
@@ -147,7 +147,7 @@
{/if}
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
exportHandler();
}}
@@ -1182,7 +1182,6 @@
</div>
</div>
</div>
</div>
{/if}
</div>
+3 -1
View File
@@ -183,7 +183,9 @@
align="end"
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span class="inline-flex h-input px-0.5 outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
<span
class="inline-flex h-input px-0.5 outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
@@ -925,7 +925,11 @@
}
// Submit the content when Enter key is pressed
if ((content !== '' || files.length > 0) && e.keyCode === 13 && !e.shiftKey) {
if (
(content !== '' || files.length > 0) &&
e.keyCode === 13 &&
!e.shiftKey
) {
submitHandler();
}
}
@@ -70,4 +70,3 @@
</div>
</div>
</Dropdown>
+4 -10
View File
@@ -171,8 +171,6 @@
let files = [];
let params = {};
$: if (chatIdProp) {
navigateHandler();
}
@@ -962,10 +960,9 @@
let contents = [];
messages.forEach((message) => {
if (message?.role !== 'user' && message?.content) {
const {
codeBlocks: codeBlocks,
htmlGroups: htmlGroups
} = getCodeBlockContents(message.content);
const { codeBlocks: codeBlocks, htmlGroups: htmlGroups } = getCodeBlockContents(
message.content
);
if (htmlGroups && htmlGroups.length > 0) {
htmlGroups.forEach((group) => {
@@ -1786,10 +1783,7 @@
const _files = structuredClone(files);
chatRequestQueues.update((q) => ({
...q,
[$chatId]: [
...(q[$chatId] ?? []),
{ id: uuidv4(), prompt: userPrompt, files: _files }
]
[$chatId]: [...(q[$chatId] ?? []), { id: uuidv4(), prompt: userPrompt, files: _files }]
}));
// Clear input
messageInput?.setText('');
+406 -261
View File
@@ -39,6 +39,7 @@
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
import FilePreview from './FileNav/FilePreview.svelte';
import FileEntryRow from './FileNav/FileEntryRow.svelte';
import BulkActionBar from './FileNav/BulkActionBar.svelte';
import PortList from './FileNav/PortList.svelte';
import PortPreview from './FileNav/PortPreview.svelte';
import XTerminal from './XTerminal.svelte';
@@ -217,7 +218,7 @@
// Svelte re-runs this block when any of them update.
let prevTerminalUrl = '';
$: {
$selectedTerminalId, $terminalServers, $settings;
($selectedTerminalId, $terminalServers, $settings);
const terminal = getTerminal();
selectedTerminal = terminal;
@@ -305,6 +306,7 @@
selectedFile = null;
previewPort = null;
clearFilePreview();
clearSelection();
currentPath = path;
savedPath = path;
pushNavHistory(path);
@@ -557,6 +559,112 @@
await loadDir(currentPath);
};
// ── Multi-select ────────────────────────────────────────────────────
let selectedEntries: Set<string> = new Set();
let lastClickedIndex: number | null = null;
let selectionMode = false;
$: selectedCount = selectedEntries.size;
$: hasSelectedFiles = [...selectedEntries].some((p) => !p.endsWith('/'));
const clearSelection = () => {
selectedEntries = new Set();
lastClickedIndex = null;
selectionMode = false;
};
const selectAll = () => {
selectedEntries = new Set(
entries.map((e) => {
const p = `${currentPath}${e.name}`;
return e.type === 'directory' ? p + '/' : p;
})
);
selectedEntries = selectedEntries; // trigger reactivity
};
const handleSelect = (entry: FileEntry, event: MouseEvent) => {
const path =
entry.type === 'directory' ? `${currentPath}${entry.name}/` : `${currentPath}${entry.name}`;
const idx = entries.indexOf(entry);
if (event.shiftKey && lastClickedIndex !== null) {
// Range select — replaces current selection with range
const start = Math.min(lastClickedIndex, idx);
const end = Math.max(lastClickedIndex, idx);
const newSet = new Set<string>();
for (let i = start; i <= end; i++) {
const e = entries[i];
const p = e.type === 'directory' ? `${currentPath}${e.name}/` : `${currentPath}${e.name}`;
newSet.add(p);
}
selectedEntries = newSet;
} else if (event.metaKey || event.ctrlKey) {
// Toggle one
if (selectedEntries.has(path)) {
selectedEntries.delete(path);
} else {
selectedEntries.add(path);
}
selectedEntries = selectedEntries;
} else {
// In selection mode (touch), toggle
if (selectedEntries.has(path)) {
selectedEntries.delete(path);
} else {
selectedEntries.add(path);
}
selectedEntries = selectedEntries;
}
lastClickedIndex = idx;
};
const enterSelectionMode = () => {
selectionMode = true;
};
const bulkDelete = async () => {
const terminal = selectedTerminal;
if (!terminal) return;
const paths = [...selectedEntries];
let ok = 0;
for (const p of paths) {
const result = await deleteEntry(terminal.url, terminal.key, p.replace(/\/$/, ''));
if (result) ok++;
}
toast[ok > 0 ? 'success' : 'error'](
$i18n.t('Deleted {{ok}} of {{total}} items', { ok, total: paths.length })
);
clearSelection();
await loadDir(currentPath);
};
const bulkDownload = async () => {
const terminal = selectedTerminal;
if (!terminal) return;
const filePaths = [...selectedEntries].filter((p) => !p.endsWith('/'));
for (const p of filePaths) {
await downloadFile(p);
}
};
// Escape to clear selection
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedCount > 0) {
e.preventDefault();
clearSelection();
}
};
// Click outside panel to clear selection
const handleWindowClick = (e: MouseEvent) => {
if (selectedCount > 0 && containerEl && !containerEl.contains(e.target as Node)) {
clearSelection();
}
};
// ── Lifecycle ────────────────────────────────────────────────────────
onMount(async () => {
const terminal = getTerminal();
@@ -657,12 +765,18 @@
bind:show={showDeleteConfirm}
on:confirm={() => {
if (deleteTarget) {
handleDelete(deleteTarget.path, deleteTarget.name);
if (deleteTarget.path === '__bulk__') {
bulkDelete();
} else {
handleDelete(deleteTarget.path, deleteTarget.name);
}
deleteTarget = null;
}
}}
/>
<svelte:window on:keydown={handleKeydown} on:click={handleWindowClick} />
{#if !selectedTerminal}
<div class="flex-1 flex flex-col items-center justify-center p-6 text-center">
<Folder className="size-6 text-gray-300 dark:text-gray-600 mb-2" />
@@ -706,248 +820,271 @@
{/if}
{#if previewPort === null}
<FileNavToolbar
breadcrumbs={buildBreadcrumbs(currentPath)}
{selectedFile}
{loading}
{canGoBack}
{canGoForward}
onGoBack={goBack}
onGoForward={goForward}
onNavigate={loadDir}
onRefresh={() => {
if (selectedFile) {
const fileName = selectedFile.split('/').pop() ?? '';
openEntry({ name: fileName, type: 'file', size: 0 });
} else {
loadDir(currentPath);
}
}}
onNewFolder={startNewFolder}
onNewFile={startNewFile}
onUploadFiles={handleUploadFiles}
onMove={handleMove}
>
{#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)}
<Tooltip content={$i18n.t('Reset view')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.resetImageView()}
aria-label={$i18n.t('Reset view')}
>
<ZoomReset className="size-3.5" />
</button>
</Tooltip>
{/if}
{#if filePdfData !== null}
<Tooltip content={$i18n.t('Reset view')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.resetPdfView()}
aria-label={$i18n.t('Reset view')}
>
<ZoomReset className="size-3.5" />
</button>
</Tooltip>
{/if}
{#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing}
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => {
if (editing) filePreviewRef?.cancelEdit();
showRaw = !showRaw;
}}
aria-label={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}
>
{#if showRaw}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5"
/>
</svg>
{/if}
</button>
</Tooltip>
{/if}
{#if isTextFile}
{#if isHtml && showRaw}
<Tooltip content={$i18n.t('Save')}>
<FileNavToolbar
breadcrumbs={buildBreadcrumbs(currentPath)}
{selectedFile}
{loading}
{canGoBack}
{canGoForward}
onGoBack={goBack}
onGoForward={goForward}
onNavigate={loadDir}
onRefresh={() => {
if (selectedFile) {
const fileName = selectedFile.split('/').pop() ?? '';
openEntry({ name: fileName, type: 'file', size: 0 });
} else {
loadDir(currentPath);
}
}}
onNewFolder={startNewFolder}
onNewFile={startNewFile}
onUploadFiles={handleUploadFiles}
onMove={handleMove}
>
{#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)}
<Tooltip content={$i18n.t('Reset view')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
aria-label={$i18n.t('Save')}
on:click={() => filePreviewRef?.resetImageView()}
aria-label={$i18n.t('Reset view')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else if isHtml}
<!-- HTML preview mode: no edit/save buttons -->
{:else if isMarkdown && showRaw}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
aria-label={$i18n.t('Save')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else if isMarkdown}
<!-- Markdown preview mode: no edit/save buttons -->
{:else if isCode}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
aria-label={$i18n.t('Save')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else if editing}
<Tooltip content={$i18n.t('Cancel')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.cancelEdit()}
aria-label={$i18n.t('Cancel')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveEdit()}
disabled={saving}
aria-label={$i18n.t('Save')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else}
<Tooltip content={$i18n.t('Edit')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.startEdit()}
aria-label={$i18n.t('Edit')}
>
<PenAlt className="size-3.5" />
<ZoomReset className="size-3.5" />
</button>
</Tooltip>
{/if}
{/if}
{#if filePdfData !== null}
<Tooltip content={$i18n.t('Reset view')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.resetPdfView()}
aria-label={$i18n.t('Reset view')}
>
<ZoomReset className="size-3.5" />
</button>
</Tooltip>
{/if}
{#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing}
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => {
if (editing) filePreviewRef?.cancelEdit();
showRaw = !showRaw;
}}
aria-label={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}
>
{#if showRaw}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5"
/>
</svg>
{/if}
</button>
</Tooltip>
{/if}
{#if isTextFile}
{#if isHtml && showRaw}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
aria-label={$i18n.t('Save')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else if isHtml}
<!-- HTML preview mode: no edit/save buttons -->
{:else if isMarkdown && showRaw}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
aria-label={$i18n.t('Save')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else if isMarkdown}
<!-- Markdown preview mode: no edit/save buttons -->
{:else if isCode}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
aria-label={$i18n.t('Save')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else if editing}
<Tooltip content={$i18n.t('Cancel')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.cancelEdit()}
aria-label={$i18n.t('Cancel')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.saveEdit()}
disabled={saving}
aria-label={$i18n.t('Save')}
>
{#if saving}
<Spinner className="size-3.5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
{:else}
<Tooltip content={$i18n.t('Edit')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => filePreviewRef?.startEdit()}
aria-label={$i18n.t('Edit')}
>
<PenAlt className="size-3.5" />
</button>
</Tooltip>
{/if}
{/if}
{#if fileContent !== null}
<Tooltip content={$i18n.t('Copy')}>
{#if fileContent !== null}
<Tooltip content={$i18n.t('Copy')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={async () => {
await navigator.clipboard.writeText(fileContent ?? '');
toast.success($i18n.t('Copied to clipboard'));
}}
aria-label={$i18n.t('Copy')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Download')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={async () => {
await navigator.clipboard.writeText(fileContent ?? '');
toast.success($i18n.t('Copied to clipboard'));
}}
aria-label={$i18n.t('Copy')}
on:click={() => downloadFile(selectedFile)}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -960,44 +1097,43 @@
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
</button>
</Tooltip>
</FileNavToolbar>
<!-- Bulk action bar -->
{#if selectedCount > 0}
<BulkActionBar
count={selectedCount}
hasFiles={hasSelectedFiles}
onDelete={() => {
deleteTarget = { path: '__bulk__', name: `${selectedCount} items` };
showDeleteConfirm = true;
}}
onDownload={bulkDownload}
onSelectAll={selectAll}
onClear={clearSelection}
/>
{/if}
<Tooltip content={$i18n.t('Download')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => downloadFile(selectedFile)}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
</button>
</Tooltip>
</FileNavToolbar>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto min-h-0 min-w-0">
<div
class="flex-1 overflow-y-auto min-h-0 min-w-0"
on:click={(e) => {
if (e.target === e.currentTarget && selectedCount > 0) clearSelection();
}}
>
{#if previewPort !== null}
<PortPreview
baseUrl={selectedTerminal?.url ?? ''}
port={previewPort}
onClose={() => { previewPort = null; }}
onClose={() => {
previewPort = null;
}}
/>
{:else if selectedFile !== null}
<FilePreview
@@ -1111,11 +1247,20 @@
{currentPath}
terminalUrl={selectedTerminal.url}
terminalKey={selectedTerminal.key}
selected={selectedEntries.has(
entry.type === 'directory'
? `${currentPath}${entry.name}/`
: `${currentPath}${entry.name}`
)}
{selectionMode}
selectedPaths={selectedEntries}
onOpen={openEntry}
onDownload={downloadFile}
onDelete={requestDelete}
onMove={handleMove}
onRename={handleRename}
onSelect={handleSelect}
onLongPress={enterSelectionMode}
/>
{/each}
</ul>
@@ -0,0 +1,95 @@
<script lang="ts">
import { getContext } from 'svelte';
import GarbageBin from '../../icons/GarbageBin.svelte';
import Tooltip from '../../common/Tooltip.svelte';
const i18n = getContext('i18n');
export let count: number = 0;
export let hasFiles: boolean = false;
export let onDelete: () => void = () => {};
export let onDownload: () => void = () => {};
export let onSelectAll: () => void = () => {};
export let onClear: () => void = () => {};
</script>
<div class="flex items-center gap-2 px-3 py-1.5 bg-gray-50 dark:bg-gray-800/50 shrink-0">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 flex-1 truncate">
{$i18n.t('{{count}} selected', { count })}
</span>
<Tooltip content={$i18n.t('Select All')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onSelectAll}
aria-label={$i18n.t('Select All')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
{#if hasFiles}
<Tooltip content={$i18n.t('Download')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onDownload}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onDelete}
aria-label={$i18n.t('Delete')}
>
<GarbageBin className="size-3.5" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Deselect')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onClear}
aria-label={$i18n.t('Deselect')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
</Tooltip>
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { getContext, tick, onDestroy } from 'svelte';
import { formatFileSize } from '$lib/utils';
import type { FileEntry } from '$lib/apis/terminal';
@@ -22,6 +22,13 @@
export let onMove: (source: string, destFolder: string) => void = () => {};
export let onRename: (oldPath: string, newName: string) => void = () => {};
// ── Selection ─────────────────────────────────────────────────────────
export let selected: boolean = false;
export let selectionMode: boolean = false;
export let selectedPaths: Set<string> = new Set();
export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {};
export let onLongPress: () => void = () => {};
let dragOverFolder = false;
// ── Rename state ─────────────────────────────────────────────────────
@@ -34,7 +41,6 @@
renaming = true;
await tick();
renameInput?.focus();
// Select the name without extension for files
if (entry.type === 'file') {
const dotIdx = entry.name.lastIndexOf('.');
renameInput?.setSelectionRange(0, dotIdx > 0 ? dotIdx : entry.name.length);
@@ -54,11 +60,69 @@
renaming = false;
renameValue = '';
};
// ── Long-press for touch selection ───────────────────────────────────
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let didLongPress = false;
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== 'touch') return;
didLongPress = false;
longPressTimer = setTimeout(() => {
didLongPress = true;
onLongPress();
onSelect(entry, e as any);
}, 500);
};
const onPointerUp = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
const onPointerCancel = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
onDestroy(() => {
if (longPressTimer) clearTimeout(longPressTimer);
});
// ── Click handler ────────────────────────────────────────────────────
const handleClick = (e: MouseEvent) => {
if (renaming) return;
if (didLongPress) {
didLongPress = false;
return;
}
// Modifier click → toggle/range select
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault();
onSelect(entry, e);
return;
}
// In selection mode (touch) → toggle select
if (selectionMode) {
onSelect(entry, e);
return;
}
// Normal click → open
onOpen(entry);
};
</script>
<li class="group">
<div
class="w-full flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 transition
class="w-full flex items-center transition
{selected ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}
{dragOverFolder
? 'bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-400 dark:ring-blue-500 ring-inset'
: ''}"
@@ -84,11 +148,11 @@
dragOverFolder = false;
try {
const data = JSON.parse(raw);
if (data.path) {
const destFolder = `${currentPath}${entry.name}/`;
// Don't allow dropping a folder onto itself
if (data.path + '/' === destFolder || data.path === destFolder) return;
onMove(data.path, destFolder);
const paths = data.paths || (data.path ? [data.path] : []);
const destFolder = `${currentPath}${entry.name}/`;
for (const p of paths) {
if (p + '/' === destFolder || p === destFolder) continue;
onMove(p, destFolder);
}
} catch {}
}}
@@ -98,12 +162,26 @@
draggable={true}
on:dragstart={(e) => {
const filePath = `${currentPath}${entry.name}`;
// Internal move data
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ path: filePath, name: entry.name })
);
// Keep existing chat-attachment drag for files
// If dragging a selected item, drag all selected
if (selected && selectedPaths.size > 1) {
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ paths: [...selectedPaths] })
);
// Custom drag ghost showing count
const ghost = document.createElement('div');
ghost.style.cssText =
'position:fixed;top:-1000px;left:-1000px;display:flex;align-items:center;gap:6px;padding:4px 10px;border-radius:8px;background:#374151;color:#fff;font-size:12px;white-space:nowrap;pointer-events:none;';
ghost.textContent = `${selectedPaths.size} items`;
document.body.appendChild(ghost);
e.dataTransfer?.setDragImage(ghost, 0, 0);
requestAnimationFrame(() => ghost.remove());
} else {
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ path: filePath, name: entry.name })
);
}
if (entry.type === 'file') {
e.dataTransfer?.setData(
'application/x-terminal-file',
@@ -116,13 +194,38 @@
);
}
}}
on:click={() => {
if (!renaming) onOpen(entry);
}}
on:pointerdown={onPointerDown}
on:pointerup={onPointerUp}
on:pointercancel={onPointerCancel}
on:click={handleClick}
on:dblclick|preventDefault|stopPropagation={() => {
startRename();
}}
>
{#if selectionMode || selected}
<!-- Checkbox indicator -->
<div
class="size-3.5 shrink-0 rounded border transition-colors flex items-center justify-center
{selected
? 'bg-blue-500 dark:bg-blue-600 border-blue-500 dark:border-blue-600 text-white'
: 'border-gray-300 dark:border-gray-600'}"
>
{#if selected}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-2.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
{/if}
{#if entry.type === 'directory'}
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
{:else}
@@ -148,8 +251,14 @@
bind:value={renameValue}
class="flex-1 text-xs bg-transparent border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500 text-gray-800 dark:text-gray-200 min-w-0"
on:keydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); submitRename(); }
if (e.key === 'Escape') { e.preventDefault(); cancelRename(); }
if (e.key === 'Enter') {
e.preventDefault();
submitRename();
}
if (e.key === 'Escape') {
e.preventDefault();
cancelRename();
}
}}
on:blur={submitRename}
on:click|stopPropagation
@@ -48,8 +48,17 @@
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
@@ -64,8 +73,17 @@
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
@@ -104,7 +122,8 @@
dragOverCrumb = null;
try {
const data = JSON.parse(raw);
if (data.path) onMove(data.path, crumb.path);
const paths = data.paths || (data.path ? [data.path] : []);
for (const p of paths) onMove(p, crumb.path);
} catch {}
}}
>
@@ -49,7 +49,7 @@
let editTextarea: HTMLTextAreaElement;
// Reset edit state when switching files
$: selectedFile, resetEdit();
$: (selectedFile, resetEdit());
const resetEdit = () => {
editing = false;
@@ -242,7 +242,7 @@
}
export let showRaw = false;
$: selectedFile, (showRaw = false); // reset to preview mode when switching files
$: (selectedFile, (showRaw = false)); // reset to preview mode when switching files
// Auto-switch to raw/editor mode for empty previewable files so the user
// can start editing immediately instead of seeing a blank preview.
@@ -5,7 +5,6 @@
const i18n = getContext('i18n');
export let baseUrl: string;
export let port: number;
export let path: string = '';
@@ -59,10 +58,14 @@
})();
const makeDisplayUrl = (p: string) => `localhost:${port}${p ? '/' + p : ''}`;
const syncUrlBar = () => { urlInput = makeDisplayUrl(path); };
const syncUrlBar = () => {
urlInput = makeDisplayUrl(path);
};
urlInput = makeDisplayUrl(path);
const refresh = () => { iframeKey += 1; };
const refresh = () => {
iframeKey += 1;
};
const openExternal = () => {
window.open(proxyUrl, '_blank', 'noopener,noreferrer');
@@ -130,8 +133,17 @@
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
@@ -146,8 +158,17 @@
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
@@ -159,17 +180,24 @@
on:click={refresh}
aria-label={$i18n.t('Refresh')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5" class:animate-spin={isLoading}>
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
class:animate-spin={isLoading}
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<!-- URL bar -->
<form
class="flex-1 min-w-0"
on:submit|preventDefault={navigateUrl}
>
<form class="flex-1 min-w-0" on:submit|preventDefault={navigateUrl}>
<input
type="text"
bind:value={urlInput}
@@ -185,8 +213,17 @@
on:click={openExternal}
aria-label={$i18n.t('Open in new tab')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z" clip-rule="evenodd" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
@@ -198,8 +235,15 @@
on:click={onClose}
aria-label={$i18n.t('Close')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
</Tooltip>
@@ -227,9 +271,18 @@
<style>
@keyframes loading-bar {
0% { width: 0; margin-left: 0; }
50% { width: 60%; margin-left: 20%; }
100% { width: 0; margin-left: 100%; }
0% {
width: 0;
margin-left: 0;
}
50% {
width: 60%;
margin-left: 20%;
}
100% {
width: 0;
margin-left: 100%;
}
}
.animate-loading-bar {
animation: loading-bar 1.5s ease-in-out infinite;
+1 -6
View File
@@ -90,8 +90,6 @@
import PlusAlt from '../icons/PlusAlt.svelte';
import Dropdown from '../common/Dropdown.svelte';
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
import Knobs from '../icons/Knobs.svelte';
import ValvesModal from '../workspace/common/ValvesModal.svelte';
@@ -808,10 +806,7 @@
e.preventDefault();
// Check if a file or a sidebar chat item is being dragged.
if (
e.dataTransfer?.types?.includes('Files') ||
e.dataTransfer?.types?.includes('text/plain')
) {
if (e.dataTransfer?.types?.includes('Files') || e.dataTransfer?.types?.includes('text/plain')) {
dragged = true;
} else {
dragged = false;
@@ -137,8 +137,6 @@
await tick();
});
</script>
{#if filteredItems.length > 0 || query.startsWith('http')}
@@ -1,9 +1,7 @@
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import { fly } from 'svelte/transition';
import { config, user, tools as _tools, mobile, knowledge } from '$lib/stores';
import { getKnowledgeBases } from '$lib/apis/knowledge';
@@ -50,7 +50,14 @@
}}
>
{#if copied}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-3.5 text-green-500">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3.5 text-green-500"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{:else}
@@ -61,23 +61,18 @@
</button>
</LinkPreview.Trigger>
<LinkPreview.Portal>
<LinkPreview.Content
class="z-[999]"
align="start"
strategy="fixed"
sideOffset={6}
>
<div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer">
{#each token.citationIdentifiers ?? token.ids as identifier}
{@const id =
typeof identifier === 'string' ? parseInt(identifier.split('#')[0]) : identifier}
<div class="">
<Source id={identifier} title={sourceIds[id - 1]} {onClick} />
</div>
{/each}
</div>
</LinkPreview.Content>
</LinkPreview.Portal>
<LinkPreview.Content class="z-[999]" align="start" strategy="fixed" sideOffset={6}>
<div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer">
{#each token.citationIdentifiers ?? token.ids as identifier}
{@const id =
typeof identifier === 'string' ? parseInt(identifier.split('#')[0]) : identifier}
<div class="">
<Source id={identifier} title={sourceIds[id - 1]} {onClick} />
</div>
{/each}
</div>
</LinkPreview.Content>
</LinkPreview.Portal>
</LinkPreview.Root>
{/if}
{:else}
@@ -169,9 +169,10 @@
$: model = $models.find((m) => m.id === message.model);
$: statusEntries = message?.statusHistory ?? [...(message?.status ? [message?.status] : [])];
$: hasVisibleStatus = (model?.info?.meta?.capabilities?.status_updates ?? true)
&& statusEntries.length > 0
&& !(statusEntries.at(-1)?.hidden ?? false);
$: hasVisibleStatus =
(model?.info?.meta?.capabilities?.status_updates ?? true) &&
statusEntries.length > 0 &&
!(statusEntries.at(-1)?.hidden ?? false);
let edit = false;
let editedContent = '';
@@ -135,7 +135,9 @@
{#if !($settings?.chatBubble ?? true)}
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
<ProfileImage
src={user?.id ? `${WEBUI_API_BASE_URL}/users/${user.id}/profile/image` : `${WEBUI_BASE_URL}/static/favicon.png`}
src={user?.id
? `${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`
: `${WEBUI_BASE_URL}/static/favicon.png`}
className={'size-8 user-message-profile-image'}
/>
</div>
@@ -457,285 +457,296 @@
transition:flyAndScale
>
<slot>
{#if searchEnabled}
<div class="flex items-center gap-2.5 px-4.5 pt-3.5 mb-1.5">
<Search className="size-4" strokeWidth="2.5" />
{#if searchEnabled}
<div class="flex items-center gap-2.5 px-4.5 pt-3.5 mb-1.5">
<Search className="size-4" strokeWidth="2.5" />
<input
id="model-search-input"
bind:value={searchValue}
class="w-full text-sm bg-transparent outline-hidden"
placeholder={searchPlaceholder}
autocomplete="off"
aria-label={$i18n.t('Search In Models')}
on:keydown={(e) => {
if (e.code === 'Enter' && filteredItems.length > 0) {
value = filteredItems[selectedModelIdx].value;
show = false;
return; // dont need to scroll on selection
} else if (e.code === 'ArrowDown') {
e.stopPropagation();
selectedModelIdx = Math.min(selectedModelIdx + 1, filteredItems.length - 1);
} else if (e.code === 'ArrowUp') {
e.stopPropagation();
selectedModelIdx = Math.max(selectedModelIdx - 1, 0);
} else {
// if the user types something, reset to the top selection.
selectedModelIdx = 0;
}
<input
id="model-search-input"
bind:value={searchValue}
class="w-full text-sm bg-transparent outline-hidden"
placeholder={searchPlaceholder}
autocomplete="off"
aria-label={$i18n.t('Search In Models')}
on:keydown={(e) => {
if (e.code === 'Enter' && filteredItems.length > 0) {
value = filteredItems[selectedModelIdx].value;
show = false;
return; // dont need to scroll on selection
} else if (e.code === 'ArrowDown') {
e.stopPropagation();
selectedModelIdx = Math.min(
selectedModelIdx + 1,
filteredItems.length - 1
);
} else if (e.code === 'ArrowUp') {
e.stopPropagation();
selectedModelIdx = Math.max(selectedModelIdx - 1, 0);
} else {
// if the user types something, reset to the top selection.
selectedModelIdx = 0;
}
const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
}}
/>
</div>
{/if}
<div class="px-2">
{#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
<div
class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none font-[450] mb-0.5"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap"
bind:this={tagsContainerElement}
>
{#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === '' &&
selectedConnectionType === ''
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === '' && selectedConnectionType === ''}
on:click={() => {
selectedConnectionType = '';
selectedTag = '';
}}
>
{$i18n.t('All')}
</button>
const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({
block: 'center',
inline: 'nearest',
behavior: 'instant'
});
}}
/>
</div>
{/if}
{#if items.find((item) => item.model?.connection_type === 'local')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType === 'local'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'local'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'local';
}}
>
{$i18n.t('Local')}
</button>
{/if}
{#if items.find((item) => item.model?.connection_type === 'external')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'external'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'external'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'external';
}}
>
{$i18n.t('External')}
</button>
{/if}
{#if items.find((item) => item.model?.direct)}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType === 'direct'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'direct'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'direct';
}}
>
{$i18n.t('Direct')}
</button>
{/if}
{#each tags as tag}
<Tooltip content={tag}>
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === tag
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === tag}
on:click={() => {
selectedConnectionType = '';
selectedTag = tag;
<div class="px-2">
{#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
<div
class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none font-[450] mb-0.5"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
{tag.length > 16 ? `${tag.slice(0, 16)}...` : tag}
</button>
</Tooltip>
{/each}
</div>
</div>
{/if}
</div>
<div
class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap"
bind:this={tagsContainerElement}
>
{#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === '' &&
selectedConnectionType === ''
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === '' && selectedConnectionType === ''}
on:click={() => {
selectedConnectionType = '';
selectedTag = '';
}}
>
{$i18n.t('All')}
</button>
{/if}
<div class="px-2.5 group relative">
{#if filteredItems.length === 0}
{#if items.length === 0 && $user?.role === 'admin'}
<div class="flex flex-col items-start justify-center py-6 px-4 text-start">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{$i18n.t('No models available')}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
{$i18n.t('Connect to an AI provider to start chatting')}
</div>
<a
href="/admin/settings/connections"
class="px-4 py-1.5 rounded-xl text-xs font-medium bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 transition"
on:click={() => {
show = false;
}}
>
{$i18n.t('Manage Connections')}
</a>
</div>
{:else}
<div class="">
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
</div>
</div>
{/if}
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="max-h-64 overflow-y-auto"
role="listbox"
aria-label={$i18n.t('Available models')}
bind:this={listContainer}
on:scroll={() => {
listScrollTop = listContainer.scrollTop;
}}
>
<div style="height: {visibleStart * ITEM_HEIGHT}px;" />
{#each filteredItems.slice(visibleStart, visibleEnd) as item, i (item.value)}
{@const index = visibleStart + i}
<ModelItem
{selectedModelIdx}
{item}
{index}
{value}
{pinModelHandler}
{unloadModelHandler}
onClick={() => {
value = item.value;
selectedModelIdx = index;
{#if items.find((item) => item.model?.connection_type === 'local')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'local'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'local'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'local';
}}
>
{$i18n.t('Local')}
</button>
{/if}
show = false;
}}
/>
{/each}
<div style="height: {(filteredItems.length - visibleEnd) * ITEM_HEIGHT}px;" />
</div>
{/if}
{#if items.find((item) => item.model?.connection_type === 'external')}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'external'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'external'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'external';
}}
>
{$i18n.t('External')}
</button>
{/if}
{#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user?.role === 'admin'}
<Tooltip
content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
placement="top-start"
>
<button
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl cursor-pointer data-highlighted:bg-muted"
on:click={() => {
pullModelHandler();
}}
>
<div class=" truncate">
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { searchValue: searchValue })}
</div>
</button>
</Tooltip>
{/if}
{#if items.find((item) => item.model?.direct)}
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
'direct'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedConnectionType === 'direct'}
on:click={() => {
selectedTag = '';
selectedConnectionType = 'direct';
}}
>
{$i18n.t('Direct')}
</button>
{/if}
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
<div
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-xl cursor-pointer data-highlighted:bg-muted"
>
<div class="flex">
<div class="mr-2.5 translate-y-0.5">
<Spinner />
</div>
<div class="flex flex-col self-start">
<div class="flex gap-1">
<div class="line-clamp-1">
Downloading "{model}"
</div>
<div class="shrink-0">
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
</div>
</div>
{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
<div class="-mt-1 h-fit text-[0.7rem] dark:text-gray-500 line-clamp-1">
{$MODEL_DOWNLOAD_POOL[model].digest}
{#each tags as tag}
<Tooltip content={tag}>
<button
class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === tag
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
aria-pressed={selectedTag === tag}
on:click={() => {
selectedConnectionType = '';
selectedTag = tag;
}}
>
{tag.length > 16 ? `${tag.slice(0, 16)}...` : tag}
</button>
</Tooltip>
{/each}
</div>
</div>
{/if}
</div>
</div>
<div class="mr-2 ml-1 translate-y-0.5">
<Tooltip content={$i18n.t('Cancel')}>
<button
class="text-gray-800 dark:text-gray-100"
aria-label={$i18n.t('Cancel download of {{model}}', { model: model })}
on:click={() => {
cancelModelPullHandler(model);
}}
>
<svg
class="w-4 h-4 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
<div class="px-2.5 group relative">
{#if filteredItems.length === 0}
{#if items.length === 0 && $user?.role === 'admin'}
<div class="flex flex-col items-start justify-center py-6 px-4 text-start">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{$i18n.t('No models available')}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
{$i18n.t('Connect to an AI provider to start chatting')}
</div>
<a
href="/admin/settings/connections"
class="px-4 py-1.5 rounded-xl text-xs font-medium bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 transition"
on:click={() => {
show = false;
}}
>
{$i18n.t('Manage Connections')}
</a>
</div>
{:else}
<div class="">
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
</div>
</div>
{/if}
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="max-h-64 overflow-y-auto"
role="listbox"
aria-label={$i18n.t('Available models')}
bind:this={listContainer}
on:scroll={() => {
listScrollTop = listContainer.scrollTop;
}}
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18 17.94 6M18 18 6.06 6"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
{/each}
</div>
<div style="height: {visibleStart * ITEM_HEIGHT}px;" />
{#each filteredItems.slice(visibleStart, visibleEnd) as item, i (item.value)}
{@const index = visibleStart + i}
<ModelItem
{selectedModelIdx}
{item}
{index}
{value}
{pinModelHandler}
{unloadModelHandler}
onClick={() => {
value = item.value;
selectedModelIdx = index;
<div class="pb-2.5"></div>
show = false;
}}
/>
{/each}
<div style="height: {(filteredItems.length - visibleEnd) * ITEM_HEIGHT}px;" />
</div>
{/if}
<div class="hidden w-[42rem]" />
<div class="hidden w-[32rem]" />
{#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user?.role === 'admin'}
<Tooltip
content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
placement="top-start"
>
<button
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl cursor-pointer data-highlighted:bg-muted"
on:click={() => {
pullModelHandler();
}}
>
<div class=" truncate">
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
searchValue: searchValue
})}
</div>
</button>
</Tooltip>
{/if}
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
<div
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-xl cursor-pointer data-highlighted:bg-muted"
>
<div class="flex">
<div class="mr-2.5 translate-y-0.5">
<Spinner />
</div>
<div class="flex flex-col self-start">
<div class="flex gap-1">
<div class="line-clamp-1">
Downloading "{model}"
</div>
<div class="shrink-0">
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
: ''}
</div>
</div>
{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
<div class="-mt-1 h-fit text-[0.7rem] dark:text-gray-500 line-clamp-1">
{$MODEL_DOWNLOAD_POOL[model].digest}
</div>
{/if}
</div>
</div>
<div class="mr-2 ml-1 translate-y-0.5">
<Tooltip content={$i18n.t('Cancel')}>
<button
class="text-gray-800 dark:text-gray-100"
aria-label={$i18n.t('Cancel download of {{model}}', { model: model })}
on:click={() => {
cancelModelPullHandler(model);
}}
>
<svg
class="w-4 h-4 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18 17.94 6M18 18 6.06 6"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
{/each}
</div>
<div class="pb-2.5"></div>
<div class="hidden w-[42rem]" />
<div class="hidden w-[32rem]" />
</slot>
</div>
</div>
@@ -316,7 +316,9 @@
>
<div class=" text-sm text-gray-500 flex-1">
{$i18n.t('Are you sure you want to delete this memory? This action cannot be undone.')}
<div class=" mt-2 bg-gray-50 dark:bg-gray-900 p-3 rounded-xl border border-gray-100 dark:border-gray-800 text-black dark:text-white whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
<div
class=" mt-2 bg-gray-50 dark:bg-gray-900 p-3 rounded-xl border border-gray-100 dark:border-gray-800 text-black dark:text-white whitespace-pre-wrap break-words max-h-32 overflow-y-auto"
>
{selectedMemory?.content}
</div>
</div>
+6 -2
View File
@@ -132,7 +132,7 @@
if (event.key === 'Escape' && show) {
show = false;
onOpenChange(false);
}
}
}
/** Close the dropdown programmatically */
@@ -155,7 +155,11 @@
});
</script>
<svelte:window on:keydown={handleKeydown} on:scroll|capture={positionContent} on:resize={positionContent} />
<svelte:window
on:keydown={handleKeydown}
on:scroll|capture={positionContent}
on:resize={positionContent}
/>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
+4 -3
View File
@@ -3,7 +3,8 @@
import { tick } from 'svelte';
/** CSS classes for the sub-content container */
export let contentClass = 'select-none rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800';
export let contentClass =
'select-none rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800';
/** Max width in px, enforced at the component level */
export let maxWidth = 200;
@@ -48,12 +49,12 @@
// Vertical positioning with robust bounds clamping (shift method)
const contentHeight = contentEl.offsetHeight || 0;
let top = rect.top;
// If it overflows the bottom edge
if (top + contentHeight + 16 > window.innerHeight) {
top = window.innerHeight - contentHeight - 16;
}
// If shifting it up causes it to overflow the top edge, cap it at 16px
if (top < 16) {
top = 16;
+4 -1
View File
@@ -155,7 +155,10 @@
placement="top"
>
<button
class="p-1.5 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition {selected === emojiItem.shortCodes[0] ? 'bg-gray-200 dark:bg-gray-700' : ''}"
class="p-1.5 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition {selected ===
emojiItem.shortCodes[0]
? 'bg-gray-200 dark:bg-gray-700'
: ''}"
on:click={() => selectEmoji(emojiItem)}
>
<img
+13 -7
View File
@@ -829,11 +829,7 @@
// Only show on empty paragraphs at root depth
return (
view.hasFocus() &&
empty &&
isRootDepth &&
isEmptyTextBlock &&
editor.isEditable
view.hasFocus() && empty && isRootDepth && isEmptyTextBlock && editor.isEditable
);
}
})
@@ -1275,11 +1271,21 @@
</script>
{#if richText && showFormattingToolbar}
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0" style="visibility: hidden; opacity: 0; position: absolute; z-index: 9999;">
<div
bind:this={bubbleMenuElement}
id="bubble-menu"
class="p-0"
style="visibility: hidden; opacity: 0; position: absolute; z-index: 9999;"
>
<FormattingButtons {editor} />
</div>
<div bind:this={floatingMenuElement} id="floating-menu" class="p-0" style="visibility: hidden; opacity: 0; position: absolute; z-index: 9999;">
<div
bind:this={floatingMenuElement}
id="floating-menu"
class="p-0"
style="visibility: hidden; opacity: 0; position: absolute; z-index: 9999;"
>
<FormattingButtons {editor} />
</div>
{/if}
+12 -14
View File
@@ -21,10 +21,12 @@
export let labelClass = '';
/** CSS classes for the dropdown content container */
export let contentClass = 'rounded-2xl min-w-[170px] p-1 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 dark:text-white shadow-lg';
export let contentClass =
'rounded-2xl min-w-[170px] p-1 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 dark:text-white shadow-lg';
/** CSS classes for each item button */
export let itemClass = 'flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl';
export let itemClass =
'flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl';
/** Alignment of the dropdown: 'start' | 'end' */
export let align = 'start';
@@ -99,7 +101,12 @@
}
</script>
<svelte:window on:click={handleWindowClick} on:keydown={handleKeydown} on:scroll|capture={positionContent} on:resize={positionContent} />
<svelte:window
on:click={handleWindowClick}
on:keydown={handleKeydown}
on:scroll|capture={positionContent}
on:resize={positionContent}
/>
<button
bind:this={triggerEl}
@@ -116,19 +123,10 @@
</button>
{#if open}
<div
use:portal
bind:this={contentEl}
class={contentClass}
transition:flyAndScale
>
<div use:portal bind:this={contentEl} class={contentClass} transition:flyAndScale>
<slot {open} {selectItem}>
{#each items as item}
<button
class={itemClass}
type="button"
on:click={() => selectItem(item)}
>
<button class={itemClass} type="button" on:click={() => selectItem(item)}>
<slot name="item" {item} selected={value === item.value}>
{item.label}
</slot>
+3 -3
View File
@@ -1,6 +1,4 @@
<script lang="ts">
import ChevronDown from '../icons/ChevronDown.svelte';
import Check from '../icons/Check.svelte';
import Search from '../icons/Search.svelte';
@@ -34,7 +32,9 @@
{placeholder}
triggerClass="relative w-full"
contentClass="w-full rounded-lg bg-white dark:bg-gray-900 dark:text-white shadow-lg border border-gray-300/30 dark:border-gray-700/40 outline-hidden"
onClose={() => { searchValue = ''; }}
onClose={() => {
searchValue = '';
}}
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
+6 -1
View File
@@ -16,7 +16,12 @@
<!-- The "1" -->
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 9v6M6 10.5l1.5-1.5" />
<!-- The ":" -->
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10.5v.01M12 13.5v.01" stroke-width="2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 10.5v.01M12 13.5v.01"
stroke-width="2"
/>
<!-- The other "1" -->
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 9v6M15 10.5l1.5-1.5" />
</svg>
@@ -308,7 +308,9 @@
</button>
{/if}
<DropdownSub contentClass="select-none rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800">
<DropdownSub
contentClass="select-none rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
>
<button
slot="trigger"
draggable="false"
@@ -393,7 +395,9 @@
</button>
{#if chatId && $folders.length > 0}
<DropdownSub contentClass="select-none rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden">
<DropdownSub
contentClass="select-none rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden"
>
<button
slot="trigger"
draggable="false"
+18 -18
View File
@@ -373,24 +373,24 @@
<div class="flex items-center">{$i18n.t('Download')}</div>
</button>
<button
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
disabled={messages.length === 0}
on:click={() => {
exportToJson();
}}
>
<div class="flex items-center line-clamp-1">
{$i18n.t('Export chat (.json)')}
</div>
</button>
<button
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
disabled={messages.length === 0}
on:click={() => {
downloadTxt();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
disabled={messages.length === 0}
on:click={() => {
exportToJson();
}}
>
<div class="flex items-center line-clamp-1">
{$i18n.t('Export chat (.json)')}
</div>
</button>
<button
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl select-none w-full"
disabled={messages.length === 0}
on:click={() => {
downloadTxt();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
</button>
</DropdownSub>
</div>
+6 -3
View File
@@ -256,7 +256,9 @@
});
models = models;
// Sync with server
await Promise.all(modelsToShow.map((model) => updateModelById(localStorage.token, model.id, model)));
await Promise.all(
modelsToShow.map((model) => updateModelById(localStorage.token, model.id, model))
);
toast.success($i18n.t('All models are now visible'));
};
@@ -268,7 +270,9 @@
});
models = models;
// Sync with server
await Promise.all(modelsToHide.map((model) => updateModelById(localStorage.token, model.id, model)));
await Promise.all(
modelsToHide.map((model) => updateModelById(localStorage.token, model.id, model))
);
toast.success($i18n.t('All models are now hidden'));
};
@@ -468,7 +472,6 @@
</button>
</div>
{/if}
</div>
</div>
@@ -5,7 +5,6 @@
import { searchNotes } from '$lib/apis/notes';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
import { decodeString } from '$lib/utils';
import Dropdown from '$lib/components/common/Dropdown.svelte';
@@ -163,7 +163,9 @@
const hasPublicWriteGrant = (grants: AccessGrant[]): boolean =>
grants.some(
(grant) =>
grant.principal_type === 'user' && grant.principal_id === '*' && grant.permission === 'write'
grant.principal_type === 'user' &&
grant.principal_id === '*' &&
grant.permission === 'write'
);
const currentGrants = (): AccessGrant[] =>
@@ -203,11 +205,7 @@
const setPublic = (isPublic: boolean) => {
// Remove all user:* grants
const filtered = currentGrants().filter(
(grant) =>
!(
grant.principal_type === 'user' &&
grant.principal_id === '*'
)
(grant) => !(grant.principal_type === 'user' && grant.principal_id === '*')
);
if (isPublic) {
filtered.push({
@@ -27,7 +27,9 @@
onChange={() => onChange(value)}
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
<span
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className=" size-3.5" strokeWidth="2.5" />
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -16,9 +16,9 @@
"title": "Arabic (Bahrain)"
},
{
"code": "az-AZ",
"title": "Azərbaycanca"
},
"code": "az-AZ",
"title": "Azərbaycanca"
},
{
"code": "eu-ES",
"title": "Basque (Euskara)"
+1 -4
View File
@@ -5,10 +5,7 @@
*/
import { getOpenAIConfig, updateOpenAIConfig } from '$lib/apis/openai';
import {
getTerminalServerConnections,
setTerminalServerConnections
} from '$lib/apis/configs';
import { getTerminalServerConnections, setTerminalServerConnections } from '$lib/apis/configs';
// ─── OpenAI Connections ─────────────────────────────────
+3 -2
View File
@@ -38,8 +38,9 @@
@layer base {
html,
pre {
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Vazirmatn', ui-sans-serif, system-ui,
'Segoe UI', Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif, 'Helvetica Neue', Arial,
font-family:
-apple-system, BlinkMacSystemFont, 'Inter', 'Vazirmatn', ui-sans-serif, system-ui, 'Segoe UI',
Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif, 'Helvetica Neue', Arial,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}