refac
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+2093
-2093
File diff suppressed because it is too large
Load Diff
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user