feat: experimental open terminal integration
This commit is contained in:
1 parent
f0c71e5a6d
commit
636ab99ad8
26 files changed
+1583
-361
No files matched your search
@@ -1,23 +1,35 @@
|
||||
<script context="module">
|
||||
let savedTab = 'controls';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { SvelteFlowProvider } from '@xyflow/svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { Pane, PaneResizer } from 'paneforge';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { onDestroy, onMount, tick, getContext } from 'svelte';
|
||||
import {
|
||||
mobile,
|
||||
showControls,
|
||||
showCallOverlay,
|
||||
showOverview,
|
||||
showArtifacts,
|
||||
showEmbeds
|
||||
showEmbeds,
|
||||
settings
|
||||
} from '$lib/stores';
|
||||
|
||||
import { uploadFile } from '$lib/apis/files';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import Controls from './Controls/Controls.svelte';
|
||||
import CallOverlay from './MessageInput/CallOverlay.svelte';
|
||||
import Drawer from '../common/Drawer.svelte';
|
||||
import Artifacts from './Artifacts.svelte';
|
||||
import Embeds from './ChatControls/Embeds.svelte';
|
||||
import FileNav from './FileNav.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let history;
|
||||
export let models = [];
|
||||
@@ -39,9 +51,55 @@
|
||||
let mediaQuery;
|
||||
let largeScreen = false;
|
||||
let dragged = false;
|
||||
|
||||
let minSize = 0;
|
||||
|
||||
// Tab state for Controls+Files panel
|
||||
let activeTab: 'controls' | 'files' = savedTab as 'controls' | 'files';
|
||||
$: savedTab = activeTab;
|
||||
$: hasTerminal = !!($settings?.terminalServers ?? []).find((s) => s.enabled)?.url;
|
||||
|
||||
// Attach a terminal file to the chat input
|
||||
const handleTerminalAttach = async (blob: Blob, name: string, contentType: string) => {
|
||||
const tempItemId = uuidv4();
|
||||
const fileItem = {
|
||||
type: 'file',
|
||||
file: '',
|
||||
id: null,
|
||||
url: '',
|
||||
name,
|
||||
collection_name: '',
|
||||
status: 'uploading',
|
||||
error: '',
|
||||
itemId: tempItemId,
|
||||
size: blob.size
|
||||
};
|
||||
|
||||
files = [...files, fileItem];
|
||||
|
||||
try {
|
||||
const file = new File([blob], name, { type: contentType || 'application/octet-stream' });
|
||||
const uploaded = await uploadFile(localStorage.token, file);
|
||||
if (!uploaded) throw new Error('Upload failed');
|
||||
|
||||
const idx = files.findIndex((f) => f.itemId === tempItemId);
|
||||
if (idx !== -1) {
|
||||
files[idx] = {
|
||||
...fileItem,
|
||||
status: 'uploaded',
|
||||
file: uploaded,
|
||||
id: uploaded.id,
|
||||
url: `${uploaded.id}`,
|
||||
collection_name: uploaded?.meta?.collection_name
|
||||
};
|
||||
files = files;
|
||||
}
|
||||
toast.success($i18n.t('File attached to chat'));
|
||||
} catch (e) {
|
||||
files = files.filter((f) => f.itemId !== tempItemId);
|
||||
toast.error($i18n.t('Failed to attach file'));
|
||||
}
|
||||
};
|
||||
|
||||
export const openPane = () => {
|
||||
if (parseInt(localStorage?.chatControlsSize)) {
|
||||
const container = document.getElementById('chat-container');
|
||||
@@ -57,7 +115,6 @@
|
||||
const handleMediaQuery = async (e) => {
|
||||
if (e.matches) {
|
||||
largeScreen = true;
|
||||
|
||||
if ($showCallOverlay) {
|
||||
showCallOverlay.set(false);
|
||||
await tick();
|
||||
@@ -65,7 +122,6 @@
|
||||
}
|
||||
} else {
|
||||
largeScreen = false;
|
||||
|
||||
if ($showCallOverlay) {
|
||||
showCallOverlay.set(false);
|
||||
await tick();
|
||||
@@ -75,36 +131,25 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseDown = (event) => {
|
||||
const onMouseDown = () => {
|
||||
dragged = true;
|
||||
};
|
||||
|
||||
const onMouseUp = (event) => {
|
||||
const onMouseUp = () => {
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// listen to resize 1024px
|
||||
mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
|
||||
mediaQuery.addEventListener('change', handleMediaQuery);
|
||||
handleMediaQuery(mediaQuery);
|
||||
|
||||
// Select the container element you want to observe
|
||||
const container = document.getElementById('chat-container');
|
||||
|
||||
// initialize the minSize based on the container width
|
||||
minSize = Math.floor((350 / container.clientWidth) * 100);
|
||||
|
||||
// Create a new ResizeObserver instance
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
const width = entry.contentRect.width;
|
||||
// calculate the percentage of 350px
|
||||
const percentage = (350 / width) * 100;
|
||||
// set the minSize to the percentage, must be an integer
|
||||
minSize = Math.floor(percentage);
|
||||
|
||||
minSize = Math.floor((350 / width) * 100);
|
||||
if ($showControls) {
|
||||
if (pane && pane.isExpanded() && pane.getSize() < minSize) {
|
||||
pane.resize(minSize);
|
||||
@@ -112,15 +157,11 @@
|
||||
let size = Math.floor(
|
||||
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
|
||||
);
|
||||
if (size < minSize) {
|
||||
pane.resize(minSize);
|
||||
}
|
||||
if (size < minSize) pane.resize(minSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing the container's size changes
|
||||
resizeObserver.observe(container);
|
||||
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
@@ -129,7 +170,6 @@
|
||||
|
||||
onDestroy(() => {
|
||||
showControls.set(false);
|
||||
|
||||
mediaQuery.removeEventListener('change', handleMediaQuery);
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
@@ -140,33 +180,26 @@
|
||||
showOverview.set(false);
|
||||
showArtifacts.set(false);
|
||||
showEmbeds.set(false);
|
||||
|
||||
if ($showCallOverlay) {
|
||||
showCallOverlay.set(false);
|
||||
}
|
||||
if ($showCallOverlay) showCallOverlay.set(false);
|
||||
};
|
||||
|
||||
$: if (!chatId) {
|
||||
closeHandler();
|
||||
}
|
||||
$: if (!chatId) closeHandler();
|
||||
|
||||
// Helper: is a "special" full-screen panel active?
|
||||
$: specialPanel = $showCallOverlay || $showOverview || $showArtifacts || $showEmbeds;
|
||||
</script>
|
||||
|
||||
{#if !largeScreen}
|
||||
{#if $showControls}
|
||||
<Drawer
|
||||
show={$showControls}
|
||||
onClose={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
onClose={() => showControls.set(false)}
|
||||
className="min-h-[100dvh] !bg-white dark:!bg-gray-850"
|
||||
>
|
||||
<div
|
||||
class=" {$showCallOverlay || $showOverview || $showArtifacts || $showEmbeds
|
||||
? ' h-screen w-full'
|
||||
: 'px-4 py-3'} h-full"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
{#if $showCallOverlay}
|
||||
<div
|
||||
class=" h-full max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
|
||||
class="h-full max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
|
||||
>
|
||||
<CallOverlay
|
||||
bind:files
|
||||
@@ -175,9 +208,7 @@
|
||||
{modelId}
|
||||
{chatId}
|
||||
{eventTarget}
|
||||
on:close={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
on:close={() => showControls.set(false)}
|
||||
/>
|
||||
</div>
|
||||
{:else if $showEmbeds}
|
||||
@@ -192,34 +223,74 @@
|
||||
const node = e.node;
|
||||
showMessage(node.data.message, true);
|
||||
}}
|
||||
onClose={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
onClose={() => showControls.set(false)}
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
<Controls
|
||||
on:close={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
{models}
|
||||
bind:chatFiles
|
||||
bind:params
|
||||
/>
|
||||
<!-- Controls + Files tabs -->
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'controls'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'controls')}
|
||||
>
|
||||
{$i18n.t('Controls')}
|
||||
</button>
|
||||
{#if hasTerminal}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'files'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'files')}
|
||||
>
|
||||
{$i18n.t('Files')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
on:click={() => showControls.set(false)}
|
||||
aria-label={$i18n.t('Close')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="size-4"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto min-h-0 {activeTab === 'controls' ? 'px-3 pt-1' : ''}"
|
||||
>
|
||||
{#if activeTab === 'files' && hasTerminal}
|
||||
<FileNav onAttach={handleTerminalAttach} />
|
||||
{:else}
|
||||
<Controls embed={true} {models} bind:chatFiles bind:params />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Drawer>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- if $showControls -->
|
||||
|
||||
{#if $showControls}
|
||||
<PaneResizer
|
||||
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
|
||||
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
|
||||
id="controls-resizer"
|
||||
>
|
||||
<div
|
||||
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
/>
|
||||
</PaneResizer>
|
||||
{/if}
|
||||
@@ -229,31 +300,25 @@
|
||||
defaultSize={0}
|
||||
onResize={(size) => {
|
||||
if ($showControls && pane.isExpanded()) {
|
||||
if (size < minSize) {
|
||||
pane.resize(minSize);
|
||||
}
|
||||
|
||||
if (size < minSize) pane.resize(minSize);
|
||||
if (size < minSize) {
|
||||
localStorage.chatControlsSize = 0;
|
||||
} else {
|
||||
// save the size in pixels to localStorage
|
||||
const container = document.getElementById('chat-container');
|
||||
localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCollapse={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
onCollapse={() => showControls.set(false)}
|
||||
collapsible={true}
|
||||
class=" z-10 bg-white dark:bg-gray-850"
|
||||
class="z-10 bg-white dark:bg-gray-850"
|
||||
>
|
||||
{#if $showControls}
|
||||
<div class="flex max-h-full min-h-full">
|
||||
<div
|
||||
class="w-full {($showOverview || $showArtifacts || $showEmbeds) && !$showCallOverlay
|
||||
class="w-full {specialPanel && !$showCallOverlay
|
||||
? ' '
|
||||
: 'px-4 py-3 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
|
||||
: 'bg-white dark:shadow-lg dark:bg-gray-850'} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
|
||||
id="controls-container"
|
||||
>
|
||||
{#if $showCallOverlay}
|
||||
@@ -265,9 +330,7 @@
|
||||
{modelId}
|
||||
{chatId}
|
||||
{eventTarget}
|
||||
on:close={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
on:close={() => showControls.set(false)}
|
||||
/>
|
||||
</div>
|
||||
{:else if $showEmbeds}
|
||||
@@ -285,23 +348,64 @@
|
||||
} else {
|
||||
history.messages[node.data.message.id].favorite = null;
|
||||
}
|
||||
|
||||
showMessage(node.data.message, true);
|
||||
}}
|
||||
onClose={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
onClose={() => showControls.set(false)}
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
<Controls
|
||||
on:close={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
{models}
|
||||
bind:chatFiles
|
||||
bind:params
|
||||
/>
|
||||
<!-- Controls + Files tabs -->
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'controls'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'controls')}
|
||||
>
|
||||
{$i18n.t('Controls')}
|
||||
</button>
|
||||
{#if hasTerminal}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'files'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'files')}
|
||||
>
|
||||
{$i18n.t('Files')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
on:click={() => showControls.set(false)}
|
||||
aria-label={$i18n.t('Close')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="size-4"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto min-h-0 {activeTab === 'controls' ? 'px-3 pt-1' : ''}"
|
||||
>
|
||||
{#if activeTab === 'files' && hasTerminal}
|
||||
<FileNav onAttach={handleTerminalAttach} />
|
||||
{:else}
|
||||
<Controls embed={true} {models} bind:chatFiles bind:params />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user