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 ### Added
- **🔗 Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality. - **🔗 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. - 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`. - When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`.
@@ -25,7 +25,8 @@
} }
html { 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', 'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium',
Roboto, 'Helvetica Neue', Arial, sans-serif; Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px; /* Default font size */ 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 <ConfirmDialog
bind:show={showDeleteConfirmDialog} 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')} confirmLabel={$i18n.t('Delete')}
on:confirm={() => { on:confirm={() => {
onDelete(); onDelete();
@@ -733,7 +733,9 @@
<ConfirmDialog <ConfirmDialog
bind:show={showDeleteConfirmDialog} 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')} confirmLabel={$i18n.t('Delete')}
on:confirm={() => { on:confirm={() => {
onDelete(); onDelete();
+3 -1
View File
@@ -930,7 +930,9 @@
<ConfirmDialog <ConfirmDialog
bind:show={showDeleteConfirmDialog} 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')} confirmLabel={$i18n.t('Delete')}
on:confirm={() => { on:confirm={() => {
onDelete(); onDelete();
@@ -31,7 +31,9 @@
onChange={() => onChange(value)} onChange={() => onChange(value)}
> >
<svelte:fragment slot="trigger" let:selectedLabel> <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} {selectedLabel}
</span> </span>
<ChevronDown className="size-3.5" strokeWidth="2.5" /> <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" 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 <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={() => { on:click={() => {
hideHandler(); hideHandler();
}} }}
@@ -102,7 +102,7 @@
</button> </button>
<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={() => { on:click={() => {
pinModelHandler(model?.id); pinModelHandler(model?.id);
}} }}
@@ -123,7 +123,7 @@
</button> </button>
<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={() => { on:click={() => {
copyLinkHandler(); copyLinkHandler();
}} }}
@@ -135,7 +135,7 @@
{#if model?.is_active ?? true} {#if model?.is_active ?? true}
<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={() => { on:click={() => {
cloneHandler(); cloneHandler();
}} }}
@@ -147,7 +147,7 @@
{/if} {/if}
<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={() => { on:click={() => {
exportHandler(); exportHandler();
}} }}
@@ -1182,7 +1182,6 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
+3 -1
View File
@@ -183,7 +183,9 @@
align="end" align="end"
> >
<svelte:fragment slot="trigger" let:selectedLabel> <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} {selectedLabel}
</span> </span>
<ChevronDown className="size-3.5" strokeWidth="2.5" /> <ChevronDown className="size-3.5" strokeWidth="2.5" />
@@ -925,7 +925,11 @@
} }
// Submit the content when Enter key is pressed // 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(); submitHandler();
} }
} }
@@ -70,4 +70,3 @@
</div> </div>
</div> </div>
</Dropdown> </Dropdown>
+4 -10
View File
@@ -171,8 +171,6 @@
let files = []; let files = [];
let params = {}; let params = {};
$: if (chatIdProp) { $: if (chatIdProp) {
navigateHandler(); navigateHandler();
} }
@@ -962,10 +960,9 @@
let contents = []; let contents = [];
messages.forEach((message) => { messages.forEach((message) => {
if (message?.role !== 'user' && message?.content) { if (message?.role !== 'user' && message?.content) {
const { const { codeBlocks: codeBlocks, htmlGroups: htmlGroups } = getCodeBlockContents(
codeBlocks: codeBlocks, message.content
htmlGroups: htmlGroups );
} = getCodeBlockContents(message.content);
if (htmlGroups && htmlGroups.length > 0) { if (htmlGroups && htmlGroups.length > 0) {
htmlGroups.forEach((group) => { htmlGroups.forEach((group) => {
@@ -1786,10 +1783,7 @@
const _files = structuredClone(files); const _files = structuredClone(files);
chatRequestQueues.update((q) => ({ chatRequestQueues.update((q) => ({
...q, ...q,
[$chatId]: [ [$chatId]: [...(q[$chatId] ?? []), { id: uuidv4(), prompt: userPrompt, files: _files }]
...(q[$chatId] ?? []),
{ id: uuidv4(), prompt: userPrompt, files: _files }
]
})); }));
// Clear input // Clear input
messageInput?.setText(''); messageInput?.setText('');
+406 -261
View File
@@ -39,6 +39,7 @@
import FileNavToolbar from './FileNav/FileNavToolbar.svelte'; import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
import FilePreview from './FileNav/FilePreview.svelte'; import FilePreview from './FileNav/FilePreview.svelte';
import FileEntryRow from './FileNav/FileEntryRow.svelte'; import FileEntryRow from './FileNav/FileEntryRow.svelte';
import BulkActionBar from './FileNav/BulkActionBar.svelte';
import PortList from './FileNav/PortList.svelte'; import PortList from './FileNav/PortList.svelte';
import PortPreview from './FileNav/PortPreview.svelte'; import PortPreview from './FileNav/PortPreview.svelte';
import XTerminal from './XTerminal.svelte'; import XTerminal from './XTerminal.svelte';
@@ -217,7 +218,7 @@
// Svelte re-runs this block when any of them update. // Svelte re-runs this block when any of them update.
let prevTerminalUrl = ''; let prevTerminalUrl = '';
$: { $: {
$selectedTerminalId, $terminalServers, $settings; ($selectedTerminalId, $terminalServers, $settings);
const terminal = getTerminal(); const terminal = getTerminal();
selectedTerminal = terminal; selectedTerminal = terminal;
@@ -305,6 +306,7 @@
selectedFile = null; selectedFile = null;
previewPort = null; previewPort = null;
clearFilePreview(); clearFilePreview();
clearSelection();
currentPath = path; currentPath = path;
savedPath = path; savedPath = path;
pushNavHistory(path); pushNavHistory(path);
@@ -557,6 +559,112 @@
await loadDir(currentPath); 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 ──────────────────────────────────────────────────────── // ── Lifecycle ────────────────────────────────────────────────────────
onMount(async () => { onMount(async () => {
const terminal = getTerminal(); const terminal = getTerminal();
@@ -657,12 +765,18 @@
bind:show={showDeleteConfirm} bind:show={showDeleteConfirm}
on:confirm={() => { on:confirm={() => {
if (deleteTarget) { if (deleteTarget) {
handleDelete(deleteTarget.path, deleteTarget.name); if (deleteTarget.path === '__bulk__') {
bulkDelete();
} else {
handleDelete(deleteTarget.path, deleteTarget.name);
}
deleteTarget = null; deleteTarget = null;
} }
}} }}
/> />
<svelte:window on:keydown={handleKeydown} on:click={handleWindowClick} />
{#if !selectedTerminal} {#if !selectedTerminal}
<div class="flex-1 flex flex-col items-center justify-center p-6 text-center"> <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" /> <Folder className="size-6 text-gray-300 dark:text-gray-600 mb-2" />
@@ -706,248 +820,271 @@
{/if} {/if}
{#if previewPort === null} {#if previewPort === null}
<FileNavToolbar <FileNavToolbar
breadcrumbs={buildBreadcrumbs(currentPath)} breadcrumbs={buildBreadcrumbs(currentPath)}
{selectedFile} {selectedFile}
{loading} {loading}
{canGoBack} {canGoBack}
{canGoForward} {canGoForward}
onGoBack={goBack} onGoBack={goBack}
onGoForward={goForward} onGoForward={goForward}
onNavigate={loadDir} onNavigate={loadDir}
onRefresh={() => { onRefresh={() => {
if (selectedFile) { if (selectedFile) {
const fileName = selectedFile.split('/').pop() ?? ''; const fileName = selectedFile.split('/').pop() ?? '';
openEntry({ name: fileName, type: 'file', size: 0 }); openEntry({ name: fileName, type: 'file', size: 0 });
} else { } else {
loadDir(currentPath); loadDir(currentPath);
} }
}} }}
onNewFolder={startNewFolder} onNewFolder={startNewFolder}
onNewFile={startNewFile} onNewFile={startNewFile}
onUploadFiles={handleUploadFiles} onUploadFiles={handleUploadFiles}
onMove={handleMove} onMove={handleMove}
> >
{#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)} {#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)}
<Tooltip content={$i18n.t('Reset view')}> <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')}>
<button <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" 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()} on:click={() => filePreviewRef?.resetImageView()}
disabled={saving} aria-label={$i18n.t('Reset view')}
aria-label={$i18n.t('Save')}
> >
{#if saving} <ZoomReset className="size-3.5" />
<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> </button>
</Tooltip> </Tooltip>
{/if} {/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} {#if fileContent !== null}
<Tooltip content={$i18n.t('Copy')}> <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 <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" 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 () => { on:click={() => downloadFile(selectedFile)}
await navigator.clipboard.writeText(fileContent ?? ''); aria-label={$i18n.t('Download')}
toast.success($i18n.t('Copied to clipboard'));
}}
aria-label={$i18n.t('Copy')}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -960,44 +1097,43 @@
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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> </svg>
</button> </button>
</Tooltip> </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} {/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} {/if}
<!-- Content --> <!-- 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} {#if previewPort !== null}
<PortPreview <PortPreview
baseUrl={selectedTerminal?.url ?? ''} baseUrl={selectedTerminal?.url ?? ''}
port={previewPort} port={previewPort}
onClose={() => { previewPort = null; }} onClose={() => {
previewPort = null;
}}
/> />
{:else if selectedFile !== null} {:else if selectedFile !== null}
<FilePreview <FilePreview
@@ -1111,11 +1247,20 @@
{currentPath} {currentPath}
terminalUrl={selectedTerminal.url} terminalUrl={selectedTerminal.url}
terminalKey={selectedTerminal.key} terminalKey={selectedTerminal.key}
selected={selectedEntries.has(
entry.type === 'directory'
? `${currentPath}${entry.name}/`
: `${currentPath}${entry.name}`
)}
{selectionMode}
selectedPaths={selectedEntries}
onOpen={openEntry} onOpen={openEntry}
onDownload={downloadFile} onDownload={downloadFile}
onDelete={requestDelete} onDelete={requestDelete}
onMove={handleMove} onMove={handleMove}
onRename={handleRename} onRename={handleRename}
onSelect={handleSelect}
onLongPress={enterSelectionMode}
/> />
{/each} {/each}
</ul> </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"> <script lang="ts">
import { getContext, tick } from 'svelte'; import { getContext, tick, onDestroy } from 'svelte';
import { formatFileSize } from '$lib/utils'; import { formatFileSize } from '$lib/utils';
import type { FileEntry } from '$lib/apis/terminal'; import type { FileEntry } from '$lib/apis/terminal';
@@ -22,6 +22,13 @@
export let onMove: (source: string, destFolder: string) => void = () => {}; export let onMove: (source: string, destFolder: string) => void = () => {};
export let onRename: (oldPath: string, newName: 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; let dragOverFolder = false;
// ── Rename state ───────────────────────────────────────────────────── // ── Rename state ─────────────────────────────────────────────────────
@@ -34,7 +41,6 @@
renaming = true; renaming = true;
await tick(); await tick();
renameInput?.focus(); renameInput?.focus();
// Select the name without extension for files
if (entry.type === 'file') { if (entry.type === 'file') {
const dotIdx = entry.name.lastIndexOf('.'); const dotIdx = entry.name.lastIndexOf('.');
renameInput?.setSelectionRange(0, dotIdx > 0 ? dotIdx : entry.name.length); renameInput?.setSelectionRange(0, dotIdx > 0 ? dotIdx : entry.name.length);
@@ -54,11 +60,69 @@
renaming = false; renaming = false;
renameValue = ''; 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> </script>
<li class="group"> <li class="group">
<div <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 {dragOverFolder
? 'bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-400 dark:ring-blue-500 ring-inset' ? '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; dragOverFolder = false;
try { try {
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (data.path) { const paths = data.paths || (data.path ? [data.path] : []);
const destFolder = `${currentPath}${entry.name}/`; const destFolder = `${currentPath}${entry.name}/`;
// Don't allow dropping a folder onto itself for (const p of paths) {
if (data.path + '/' === destFolder || data.path === destFolder) return; if (p + '/' === destFolder || p === destFolder) continue;
onMove(data.path, destFolder); onMove(p, destFolder);
} }
} catch {} } catch {}
}} }}
@@ -98,12 +162,26 @@
draggable={true} draggable={true}
on:dragstart={(e) => { on:dragstart={(e) => {
const filePath = `${currentPath}${entry.name}`; const filePath = `${currentPath}${entry.name}`;
// Internal move data // If dragging a selected item, drag all selected
e.dataTransfer?.setData( if (selected && selectedPaths.size > 1) {
'application/x-terminal-file-move', e.dataTransfer?.setData(
JSON.stringify({ path: filePath, name: entry.name }) 'application/x-terminal-file-move',
); JSON.stringify({ paths: [...selectedPaths] })
// Keep existing chat-attachment drag for files );
// 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') { if (entry.type === 'file') {
e.dataTransfer?.setData( e.dataTransfer?.setData(
'application/x-terminal-file', 'application/x-terminal-file',
@@ -116,13 +194,38 @@
); );
} }
}} }}
on:click={() => { on:pointerdown={onPointerDown}
if (!renaming) onOpen(entry); on:pointerup={onPointerUp}
}} on:pointercancel={onPointerCancel}
on:click={handleClick}
on:dblclick|preventDefault|stopPropagation={() => { on:dblclick|preventDefault|stopPropagation={() => {
startRename(); 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'} {#if entry.type === 'directory'}
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" /> <Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
{:else} {:else}
@@ -148,8 +251,14 @@
bind:value={renameValue} 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" 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) => { on:keydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); submitRename(); } if (e.key === 'Enter') {
if (e.key === 'Escape') { e.preventDefault(); cancelRename(); } e.preventDefault();
submitRename();
}
if (e.key === 'Escape') {
e.preventDefault();
cancelRename();
}
}} }}
on:blur={submitRename} on:blur={submitRename}
on:click|stopPropagation on:click|stopPropagation
@@ -48,8 +48,17 @@
disabled={!canGoBack} disabled={!canGoBack}
aria-label={$i18n.t('Back')} aria-label={$i18n.t('Back')}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5"> <svg
<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" /> 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> </svg>
</button> </button>
</Tooltip> </Tooltip>
@@ -64,8 +73,17 @@
disabled={!canGoForward} disabled={!canGoForward}
aria-label={$i18n.t('Forward')} aria-label={$i18n.t('Forward')}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5"> <svg
<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" /> 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> </svg>
</button> </button>
</Tooltip> </Tooltip>
@@ -104,7 +122,8 @@
dragOverCrumb = null; dragOverCrumb = null;
try { try {
const data = JSON.parse(raw); 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 {} } catch {}
}} }}
> >
@@ -49,7 +49,7 @@
let editTextarea: HTMLTextAreaElement; let editTextarea: HTMLTextAreaElement;
// Reset edit state when switching files // Reset edit state when switching files
$: selectedFile, resetEdit(); $: (selectedFile, resetEdit());
const resetEdit = () => { const resetEdit = () => {
editing = false; editing = false;
@@ -242,7 +242,7 @@
} }
export let showRaw = false; 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 // Auto-switch to raw/editor mode for empty previewable files so the user
// can start editing immediately instead of seeing a blank preview. // can start editing immediately instead of seeing a blank preview.
@@ -5,7 +5,6 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let baseUrl: string; export let baseUrl: string;
export let port: number; export let port: number;
export let path: string = ''; export let path: string = '';
@@ -59,10 +58,14 @@
})(); })();
const makeDisplayUrl = (p: string) => `localhost:${port}${p ? '/' + p : ''}`; const makeDisplayUrl = (p: string) => `localhost:${port}${p ? '/' + p : ''}`;
const syncUrlBar = () => { urlInput = makeDisplayUrl(path); }; const syncUrlBar = () => {
urlInput = makeDisplayUrl(path);
};
urlInput = makeDisplayUrl(path); urlInput = makeDisplayUrl(path);
const refresh = () => { iframeKey += 1; }; const refresh = () => {
iframeKey += 1;
};
const openExternal = () => { const openExternal = () => {
window.open(proxyUrl, '_blank', 'noopener,noreferrer'); window.open(proxyUrl, '_blank', 'noopener,noreferrer');
@@ -130,8 +133,17 @@
disabled={!canGoBack} disabled={!canGoBack}
aria-label={$i18n.t('Back')} aria-label={$i18n.t('Back')}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5"> <svg
<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" /> 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> </svg>
</button> </button>
</Tooltip> </Tooltip>
@@ -146,8 +158,17 @@
disabled={!canGoForward} disabled={!canGoForward}
aria-label={$i18n.t('Forward')} aria-label={$i18n.t('Forward')}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5"> <svg
<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" /> 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> </svg>
</button> </button>
</Tooltip> </Tooltip>
@@ -159,17 +180,24 @@
on:click={refresh} on:click={refresh}
aria-label={$i18n.t('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}> <svg
<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" /> 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> </svg>
</button> </button>
</Tooltip> </Tooltip>
<!-- URL bar --> <!-- URL bar -->
<form <form class="flex-1 min-w-0" on:submit|preventDefault={navigateUrl}>
class="flex-1 min-w-0"
on:submit|preventDefault={navigateUrl}
>
<input <input
type="text" type="text"
bind:value={urlInput} bind:value={urlInput}
@@ -185,8 +213,17 @@
on:click={openExternal} on:click={openExternal}
aria-label={$i18n.t('Open in new tab')} 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"> <svg
<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" /> 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> </svg>
</button> </button>
</Tooltip> </Tooltip>
@@ -198,8 +235,15 @@
on:click={onClose} on:click={onClose}
aria-label={$i18n.t('Close')} aria-label={$i18n.t('Close')}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5"> <svg
<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" /> 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> </svg>
</button> </button>
</Tooltip> </Tooltip>
@@ -227,9 +271,18 @@
<style> <style>
@keyframes loading-bar { @keyframes loading-bar {
0% { width: 0; margin-left: 0; } 0% {
50% { width: 60%; margin-left: 20%; } width: 0;
100% { width: 0; margin-left: 100%; } margin-left: 0;
}
50% {
width: 60%;
margin-left: 20%;
}
100% {
width: 0;
margin-left: 100%;
}
} }
.animate-loading-bar { .animate-loading-bar {
animation: loading-bar 1.5s ease-in-out infinite; animation: loading-bar 1.5s ease-in-out infinite;
+1 -6
View File
@@ -90,8 +90,6 @@
import PlusAlt from '../icons/PlusAlt.svelte'; import PlusAlt from '../icons/PlusAlt.svelte';
import Dropdown from '../common/Dropdown.svelte'; import Dropdown from '../common/Dropdown.svelte';
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte'; import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
import Knobs from '../icons/Knobs.svelte'; import Knobs from '../icons/Knobs.svelte';
import ValvesModal from '../workspace/common/ValvesModal.svelte'; import ValvesModal from '../workspace/common/ValvesModal.svelte';
@@ -808,10 +806,7 @@
e.preventDefault(); e.preventDefault();
// Check if a file or a sidebar chat item is being dragged. // Check if a file or a sidebar chat item is being dragged.
if ( if (e.dataTransfer?.types?.includes('Files') || e.dataTransfer?.types?.includes('text/plain')) {
e.dataTransfer?.types?.includes('Files') ||
e.dataTransfer?.types?.includes('text/plain')
) {
dragged = true; dragged = true;
} else { } else {
dragged = false; dragged = false;
@@ -137,8 +137,6 @@
await tick(); await tick();
}); });
</script> </script>
{#if filteredItems.length > 0 || query.startsWith('http')} {#if filteredItems.length > 0 || query.startsWith('http')}
@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext, onMount, tick } from 'svelte'; import { getContext, onMount, tick } from 'svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { config, user, tools as _tools, mobile, knowledge } from '$lib/stores'; import { config, user, tools as _tools, mobile, knowledge } from '$lib/stores';
import { getKnowledgeBases } from '$lib/apis/knowledge'; import { getKnowledgeBases } from '$lib/apis/knowledge';
@@ -50,7 +50,14 @@
}} }}
> >
{#if copied} {#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" /> <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg> </svg>
{:else} {:else}
@@ -61,23 +61,18 @@
</button> </button>
</LinkPreview.Trigger> </LinkPreview.Trigger>
<LinkPreview.Portal> <LinkPreview.Portal>
<LinkPreview.Content <LinkPreview.Content class="z-[999]" align="start" strategy="fixed" sideOffset={6}>
class="z-[999]" <div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer">
align="start" {#each token.citationIdentifiers ?? token.ids as identifier}
strategy="fixed" {@const id =
sideOffset={6} typeof identifier === 'string' ? parseInt(identifier.split('#')[0]) : identifier}
> <div class="">
<div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer"> <Source id={identifier} title={sourceIds[id - 1]} {onClick} />
{#each token.citationIdentifiers ?? token.ids as identifier} </div>
{@const id = {/each}
typeof identifier === 'string' ? parseInt(identifier.split('#')[0]) : identifier} </div>
<div class=""> </LinkPreview.Content>
<Source id={identifier} title={sourceIds[id - 1]} {onClick} /> </LinkPreview.Portal>
</div>
{/each}
</div>
</LinkPreview.Content>
</LinkPreview.Portal>
</LinkPreview.Root> </LinkPreview.Root>
{/if} {/if}
{:else} {:else}
@@ -169,9 +169,10 @@
$: model = $models.find((m) => m.id === message.model); $: model = $models.find((m) => m.id === message.model);
$: statusEntries = message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]; $: statusEntries = message?.statusHistory ?? [...(message?.status ? [message?.status] : [])];
$: hasVisibleStatus = (model?.info?.meta?.capabilities?.status_updates ?? true) $: hasVisibleStatus =
&& statusEntries.length > 0 (model?.info?.meta?.capabilities?.status_updates ?? true) &&
&& !(statusEntries.at(-1)?.hidden ?? false); statusEntries.length > 0 &&
!(statusEntries.at(-1)?.hidden ?? false);
let edit = false; let edit = false;
let editedContent = ''; let editedContent = '';
@@ -135,7 +135,9 @@
{#if !($settings?.chatBubble ?? true)} {#if !($settings?.chatBubble ?? true)}
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}> <div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
<ProfileImage <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'} className={'size-8 user-message-profile-image'}
/> />
</div> </div>
@@ -457,285 +457,296 @@
transition:flyAndScale transition:flyAndScale
> >
<slot> <slot>
{#if searchEnabled} {#if searchEnabled}
<div class="flex items-center gap-2.5 px-4.5 pt-3.5 mb-1.5"> <div class="flex items-center gap-2.5 px-4.5 pt-3.5 mb-1.5">
<Search className="size-4" strokeWidth="2.5" /> <Search className="size-4" strokeWidth="2.5" />
<input <input
id="model-search-input" id="model-search-input"
bind:value={searchValue} bind:value={searchValue}
class="w-full text-sm bg-transparent outline-hidden" class="w-full text-sm bg-transparent outline-hidden"
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
autocomplete="off" autocomplete="off"
aria-label={$i18n.t('Search In Models')} aria-label={$i18n.t('Search In Models')}
on:keydown={(e) => { on:keydown={(e) => {
if (e.code === 'Enter' && filteredItems.length > 0) { if (e.code === 'Enter' && filteredItems.length > 0) {
value = filteredItems[selectedModelIdx].value; value = filteredItems[selectedModelIdx].value;
show = false; show = false;
return; // dont need to scroll on selection return; // dont need to scroll on selection
} else if (e.code === 'ArrowDown') { } else if (e.code === 'ArrowDown') {
e.stopPropagation(); e.stopPropagation();
selectedModelIdx = Math.min(selectedModelIdx + 1, filteredItems.length - 1); selectedModelIdx = Math.min(
} else if (e.code === 'ArrowUp') { selectedModelIdx + 1,
e.stopPropagation(); filteredItems.length - 1
selectedModelIdx = Math.max(selectedModelIdx - 1, 0); );
} else { } else if (e.code === 'ArrowUp') {
// if the user types something, reset to the top selection. e.stopPropagation();
selectedModelIdx = 0; 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"]`); const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); item?.scrollIntoView({
}} block: 'center',
/> inline: 'nearest',
</div> behavior: 'instant'
{/if} });
}}
<div class="px-2"> />
{#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0} </div>
<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>
{/if} {/if}
{#if items.find((item) => item.model?.connection_type === 'local')} <div class="px-2">
<button {#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType === 'local' <div
? '' class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none font-[450] mb-0.5"
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize" on:wheel={(e) => {
aria-pressed={selectedConnectionType === 'local'} if (e.deltaY !== 0) {
on:click={() => { e.preventDefault();
selectedTag = ''; e.currentTarget.scrollLeft += e.deltaY;
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;
}} }}
> >
{tag.length > 16 ? `${tag.slice(0, 16)}...` : tag} <div
</button> class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap"
</Tooltip> bind:this={tagsContainerElement}
{/each} >
</div> {#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}
</div> <button
{/if} class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === '' &&
</div> 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 items.find((item) => item.model?.connection_type === 'local')}
{#if filteredItems.length === 0} <button
{#if items.length === 0 && $user?.role === 'admin'} class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
<div class="flex flex-col items-start justify-center py-6 px-4 text-start"> 'local'
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1"> ? ''
{$i18n.t('No models available')} : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
</div> aria-pressed={selectedConnectionType === 'local'}
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4"> on:click={() => {
{$i18n.t('Connect to an AI provider to start chatting')} selectedTag = '';
</div> selectedConnectionType = 'local';
<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" {$i18n.t('Local')}
on:click={() => { </button>
show = false; {/if}
}}
>
{$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;
show = false; {#if items.find((item) => item.model?.connection_type === 'external')}
}} <button
/> class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
{/each} 'external'
<div style="height: {(filteredItems.length - visibleEnd) * ITEM_HEIGHT}px;" /> ? ''
</div> : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
{/if} 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'} {#if items.find((item) => item.model?.direct)}
<Tooltip <button
content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { class="min-w-fit outline-none px-1.5 py-0.5 {selectedConnectionType ===
searchValue: searchValue 'direct'
})} ? ''
placement="top-start" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
> aria-pressed={selectedConnectionType === 'direct'}
<button on:click={() => {
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" selectedTag = '';
on:click={() => { selectedConnectionType = 'direct';
pullModelHandler(); }}
}} >
> {$i18n.t('Direct')}
<div class=" truncate"> </button>
{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { searchValue: searchValue })} {/if}
</div>
</button>
</Tooltip>
{/if}
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model} {#each tags as tag}
<div <Tooltip content={tag}>
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" <button
> class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === tag
<div class="flex"> ? ''
<div class="mr-2.5 translate-y-0.5"> : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
<Spinner /> aria-pressed={selectedTag === tag}
</div> on:click={() => {
selectedConnectionType = '';
<div class="flex flex-col self-start"> selectedTag = tag;
<div class="flex gap-1"> }}
<div class="line-clamp-1"> >
Downloading "{model}" {tag.length > 16 ? `${tag.slice(0, 16)}...` : tag}
</div> </button>
</Tooltip>
<div class="shrink-0"> {/each}
{'pullProgress' in $MODEL_DOWNLOAD_POOL[model] </div>
? `(${$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> </div>
{/if} {/if}
</div> </div>
</div>
<div class="mr-2 ml-1 translate-y-0.5"> <div class="px-2.5 group relative">
<Tooltip content={$i18n.t('Cancel')}> {#if filteredItems.length === 0}
<button {#if items.length === 0 && $user?.role === 'admin'}
class="text-gray-800 dark:text-gray-100" <div class="flex flex-col items-start justify-center py-6 px-4 text-start">
aria-label={$i18n.t('Cancel download of {{model}}', { model: model })} <div class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
on:click={() => { {$i18n.t('No models available')}
cancelModelPullHandler(model); </div>
}} <div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
> {$i18n.t('Connect to an AI provider to start chatting')}
<svg </div>
class="w-4 h-4 text-gray-800 dark:text-white" <a
aria-hidden="true" href="/admin/settings/connections"
xmlns="http://www.w3.org/2000/svg" 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"
width="24" on:click={() => {
height="24" show = false;
fill="currentColor" }}
viewBox="0 0 24 24" >
{$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 <div style="height: {visibleStart * ITEM_HEIGHT}px;" />
stroke="currentColor" {#each filteredItems.slice(visibleStart, visibleEnd) as item, i (item.value)}
stroke-linecap="round" {@const index = visibleStart + i}
stroke-linejoin="round" <ModelItem
stroke-width="2" {selectedModelIdx}
d="M6 18 17.94 6M18 18 6.06 6" {item}
/> {index}
</svg> {value}
</button> {pinModelHandler}
</Tooltip> {unloadModelHandler}
</div> onClick={() => {
</div> value = item.value;
{/each} selectedModelIdx = index;
</div>
<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]" /> {#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user?.role === 'admin'}
<div class="hidden w-[32rem]" /> <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> </slot>
</div> </div>
</div> </div>
@@ -316,7 +316,9 @@
> >
<div class=" text-sm text-gray-500 flex-1"> <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.')} {$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} {selectedMemory?.content}
</div> </div>
</div> </div>
+6 -2
View File
@@ -132,7 +132,7 @@
if (event.key === 'Escape' && show) { if (event.key === 'Escape' && show) {
show = false; show = false;
onOpenChange(false); onOpenChange(false);
} }
} }
/** Close the dropdown programmatically */ /** Close the dropdown programmatically */
@@ -155,7 +155,11 @@
}); });
</script> </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-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
+4 -3
View File
@@ -3,7 +3,8 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
/** CSS classes for the sub-content container */ /** 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 */ /** Max width in px, enforced at the component level */
export let maxWidth = 200; export let maxWidth = 200;
@@ -48,12 +49,12 @@
// Vertical positioning with robust bounds clamping (shift method) // Vertical positioning with robust bounds clamping (shift method)
const contentHeight = contentEl.offsetHeight || 0; const contentHeight = contentEl.offsetHeight || 0;
let top = rect.top; let top = rect.top;
// If it overflows the bottom edge // If it overflows the bottom edge
if (top + contentHeight + 16 > window.innerHeight) { if (top + contentHeight + 16 > window.innerHeight) {
top = window.innerHeight - contentHeight - 16; top = window.innerHeight - contentHeight - 16;
} }
// If shifting it up causes it to overflow the top edge, cap it at 16px // If shifting it up causes it to overflow the top edge, cap it at 16px
if (top < 16) { if (top < 16) {
top = 16; top = 16;
+4 -1
View File
@@ -155,7 +155,10 @@
placement="top" placement="top"
> >
<button <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)} on:click={() => selectEmoji(emojiItem)}
> >
<img <img
+13 -7
View File
@@ -829,11 +829,7 @@
// Only show on empty paragraphs at root depth // Only show on empty paragraphs at root depth
return ( return (
view.hasFocus() && view.hasFocus() && empty && isRootDepth && isEmptyTextBlock && editor.isEditable
empty &&
isRootDepth &&
isEmptyTextBlock &&
editor.isEditable
); );
} }
}) })
@@ -1275,11 +1271,21 @@
</script> </script>
{#if richText && showFormattingToolbar} {#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} /> <FormattingButtons {editor} />
</div> </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} /> <FormattingButtons {editor} />
</div> </div>
{/if} {/if}
+12 -14
View File
@@ -21,10 +21,12 @@
export let labelClass = ''; export let labelClass = '';
/** CSS classes for the dropdown content container */ /** 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 */ /** 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' */ /** Alignment of the dropdown: 'start' | 'end' */
export let align = 'start'; export let align = 'start';
@@ -99,7 +101,12 @@
} }
</script> </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 <button
bind:this={triggerEl} bind:this={triggerEl}
@@ -116,19 +123,10 @@
</button> </button>
{#if open} {#if open}
<div <div use:portal bind:this={contentEl} class={contentClass} transition:flyAndScale>
use:portal
bind:this={contentEl}
class={contentClass}
transition:flyAndScale
>
<slot {open} {selectItem}> <slot {open} {selectItem}>
{#each items as item} {#each items as item}
<button <button class={itemClass} type="button" on:click={() => selectItem(item)}>
class={itemClass}
type="button"
on:click={() => selectItem(item)}
>
<slot name="item" {item} selected={value === item.value}> <slot name="item" {item} selected={value === item.value}>
{item.label} {item.label}
</slot> </slot>
+3 -3
View File
@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import ChevronDown from '../icons/ChevronDown.svelte'; import ChevronDown from '../icons/ChevronDown.svelte';
import Check from '../icons/Check.svelte'; import Check from '../icons/Check.svelte';
import Search from '../icons/Search.svelte'; import Search from '../icons/Search.svelte';
@@ -34,7 +32,9 @@
{placeholder} {placeholder}
triggerClass="relative w-full" 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" 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> <svelte:fragment slot="trigger" let:selectedLabel>
<span <span
+6 -1
View File
@@ -16,7 +16,12 @@
<!-- The "1" --> <!-- The "1" -->
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 9v6M6 10.5l1.5-1.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 9v6M6 10.5l1.5-1.5" />
<!-- The ":" --> <!-- 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" --> <!-- The other "1" -->
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 9v6M15 10.5l1.5-1.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 9v6M15 10.5l1.5-1.5" />
</svg> </svg>
@@ -308,7 +308,9 @@
</button> </button>
{/if} {/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 <button
slot="trigger" slot="trigger"
draggable="false" draggable="false"
@@ -393,7 +395,9 @@
</button> </button>
{#if chatId && $folders.length > 0} {#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 <button
slot="trigger" slot="trigger"
draggable="false" draggable="false"
+18 -18
View File
@@ -373,24 +373,24 @@
<div class="flex items-center">{$i18n.t('Download')}</div> <div class="flex items-center">{$i18n.t('Download')}</div>
</button> </button>
<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" 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} disabled={messages.length === 0}
on:click={() => { on:click={() => {
exportToJson(); exportToJson();
}} }}
> >
<div class="flex items-center line-clamp-1"> <div class="flex items-center line-clamp-1">
{$i18n.t('Export chat (.json)')} {$i18n.t('Export chat (.json)')}
</div> </div>
</button> </button>
<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" 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} disabled={messages.length === 0}
on:click={() => { on:click={() => {
downloadTxt(); downloadTxt();
}} }}
> >
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div> <div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
</button> </button>
</DropdownSub> </DropdownSub>
</div> </div>
+6 -3
View File
@@ -256,7 +256,9 @@
}); });
models = models; models = models;
// Sync with server // 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')); toast.success($i18n.t('All models are now visible'));
}; };
@@ -268,7 +270,9 @@
}); });
models = models; models = models;
// Sync with server // 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')); toast.success($i18n.t('All models are now hidden'));
}; };
@@ -468,7 +472,6 @@
</button> </button>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -5,7 +5,6 @@
import { searchNotes } from '$lib/apis/notes'; import { searchNotes } from '$lib/apis/notes';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge'; import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
import { decodeString } from '$lib/utils'; import { decodeString } from '$lib/utils';
import Dropdown from '$lib/components/common/Dropdown.svelte'; import Dropdown from '$lib/components/common/Dropdown.svelte';
@@ -163,7 +163,9 @@
const hasPublicWriteGrant = (grants: AccessGrant[]): boolean => const hasPublicWriteGrant = (grants: AccessGrant[]): boolean =>
grants.some( grants.some(
(grant) => (grant) =>
grant.principal_type === 'user' && grant.principal_id === '*' && grant.permission === 'write' grant.principal_type === 'user' &&
grant.principal_id === '*' &&
grant.permission === 'write'
); );
const currentGrants = (): AccessGrant[] => const currentGrants = (): AccessGrant[] =>
@@ -203,11 +205,7 @@
const setPublic = (isPublic: boolean) => { const setPublic = (isPublic: boolean) => {
// Remove all user:* grants // Remove all user:* grants
const filtered = currentGrants().filter( const filtered = currentGrants().filter(
(grant) => (grant) => !(grant.principal_type === 'user' && grant.principal_id === '*')
!(
grant.principal_type === 'user' &&
grant.principal_id === '*'
)
); );
if (isPublic) { if (isPublic) {
filtered.push({ filtered.push({
@@ -27,7 +27,9 @@
onChange={() => onChange(value)} onChange={() => onChange(value)}
> >
<svelte:fragment slot="trigger" let:selectedLabel> <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} {selectedLabel}
</span> </span>
<ChevronDown className=" size-3.5" strokeWidth="2.5" /> <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)" "title": "Arabic (Bahrain)"
}, },
{ {
"code": "az-AZ", "code": "az-AZ",
"title": "Azərbaycanca" "title": "Azərbaycanca"
}, },
{ {
"code": "eu-ES", "code": "eu-ES",
"title": "Basque (Euskara)" "title": "Basque (Euskara)"
+1 -4
View File
@@ -5,10 +5,7 @@
*/ */
import { getOpenAIConfig, updateOpenAIConfig } from '$lib/apis/openai'; import { getOpenAIConfig, updateOpenAIConfig } from '$lib/apis/openai';
import { import { getTerminalServerConnections, setTerminalServerConnections } from '$lib/apis/configs';
getTerminalServerConnections,
setTerminalServerConnections
} from '$lib/apis/configs';
// ─── OpenAI Connections ───────────────────────────────── // ─── OpenAI Connections ─────────────────────────────────
+3 -2
View File
@@ -38,8 +38,9 @@
@layer base { @layer base {
html, html,
pre { pre {
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Vazirmatn', ui-sans-serif, system-ui, font-family:
'Segoe UI', Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif, 'Helvetica Neue', Arial, -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'; 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
} }