feat: chat overview

This commit is contained in:
Timothy J. Baek 2024-09-17 22:05:19 +02:00
parent bb087a5989
commit d1dbb9a3be
14 changed files with 364 additions and 79 deletions

View File

@ -156,3 +156,7 @@ input[type='number'] {
font-weight: 600; font-weight: 600;
@apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5; @apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5;
} }
.svelte-flow {
background-color: transparent !important;
}

View File

@ -23,6 +23,7 @@
banners, banners,
user, user,
socket, socket,
showControls,
showCallOverlay, showCallOverlay,
currentChatPage, currentChatPage,
temporaryChatEnabled temporaryChatEnabled
@ -70,7 +71,6 @@
let loaded = false; let loaded = false;
const eventTarget = new EventTarget(); const eventTarget = new EventTarget();
let showControls = false;
let stopResponseFlag = false; let stopResponseFlag = false;
let autoScroll = true; let autoScroll = true;
let processing = ''; let processing = '';
@ -1703,7 +1703,6 @@
{title} {title}
bind:selectedModels bind:selectedModels
bind:showModelSelector bind:showModelSelector
bind:showControls
shareEnabled={messages.length > 0} shareEnabled={messages.length > 0}
{chat} {chat}
{initNewChat} {initNewChat}
@ -1713,7 +1712,7 @@
<div <div
class="absolute top-[4.25rem] w-full {$showSidebar class="absolute top-[4.25rem] w-full {$showSidebar
? 'md:max-w-[calc(100%-260px)]' ? 'md:max-w-[calc(100%-260px)]'
: ''} {showControls ? 'lg:pr-[24rem]' : ''} z-20" : ''} {$showControls ? 'lg:pr-[24rem]' : ''} z-20"
> >
<div class=" flex flex-col gap-1 w-full"> <div class=" flex flex-col gap-1 w-full">
{#each $banners.filter( (b) => (b.dismissible ? !JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]').includes(b.id) : true) ) as banner} {#each $banners.filter( (b) => (b.dismissible ? !JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]').includes(b.id) : true) ) as banner}
@ -1740,7 +1739,7 @@
<div class="flex flex-col flex-auto z-10"> <div class="flex flex-col flex-auto z-10">
<div <div
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden {showControls class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden {$showControls
? 'lg:pr-[24rem]' ? 'lg:pr-[24rem]'
: ''}" : ''}"
id="messages-container" id="messages-container"
@ -1770,7 +1769,7 @@
</div> </div>
</div> </div>
<div class={showControls ? 'lg:pr-[24rem]' : ''}> <div class={$showControls ? 'lg:pr-[24rem]' : ''}>
<MessageInput <MessageInput
bind:files bind:files
bind:prompt bind:prompt
@ -1791,7 +1790,7 @@
{submitPrompt} {submitPrompt}
{stopResponse} {stopResponse}
on:call={() => { on:call={() => {
showControls = true; showControls.set(true);
}} }}
/> />
</div> </div>
@ -1807,7 +1806,7 @@
} }
return a; return a;
}, [])} }, [])}
bind:show={showControls} bind:history
bind:chatFiles bind:chatFiles
bind:params bind:params
bind:files bind:files

View File

@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import { SvelteFlowProvider } from '@xyflow/svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { onMount } from 'svelte';
import { mobile, showControls, showCallOverlay, showOverview } from '$lib/stores';
import Modal from '../common/Modal.svelte'; import Modal from '../common/Modal.svelte';
import Controls from './Controls/Controls.svelte'; import Controls from './Controls/Controls.svelte';
import { onMount } from 'svelte';
import { mobile, showCallOverlay } from '$lib/stores';
import CallOverlay from './MessageInput/CallOverlay.svelte'; import CallOverlay from './MessageInput/CallOverlay.svelte';
import Drawer from '../common/Drawer.svelte'; import Drawer from '../common/Drawer.svelte';
import Overview from './Overview.svelte';
export let show = false; export let history;
export let models = []; export let models = [];
export let chatId = null; export let chatId = null;
@ -44,46 +47,13 @@
}); });
</script> </script>
{#if !largeScreen} <SvelteFlowProvider>
{#if $showCallOverlay} {#if !largeScreen}
<div class=" absolute w-full h-screen max-h-[100dvh] flex z-[999] overflow-hidden"> {#if $showCallOverlay}
<div <div class=" absolute w-full h-screen max-h-[100dvh] flex z-[999] overflow-hidden">
class="absolute w-full h-screen max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center" <div
> class="absolute w-full h-screen max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
<CallOverlay >
bind:files
{submitPrompt}
{stopResponse}
{modelId}
{chatId}
{eventTarget}
on:close={() => {
show = false;
}}
/>
</div>
</div>
{:else if show}
<Drawer bind:show>
<div class=" px-6 py-4 h-full">
<Controls
on:close={() => {
show = false;
}}
{models}
bind:chatFiles
bind:params
/>
</div>
</Drawer>
{/if}
{:else if show}
<div class=" absolute bottom-0 right-0 z-20 h-full pointer-events-none">
<div class="pr-4 pt-14 pb-8 w-[24rem] h-full" in:slide={{ duration: 200, axis: 'x' }}>
<div
class="w-full h-full px-5 py-4 bg-white dark:shadow-lg dark:bg-gray-850 border border-gray-50 dark:border-gray-800 rounded-xl z-50 pointer-events-auto overflow-y-auto scrollbar-hidden"
>
{#if $showCallOverlay}
<CallOverlay <CallOverlay
bind:files bind:files
{submitPrompt} {submitPrompt}
@ -92,20 +62,68 @@
{chatId} {chatId}
{eventTarget} {eventTarget}
on:close={() => { on:close={() => {
show = false; showControls.set(false);
}} }}
/> />
{:else} </div>
</div>
{:else if $showControls}
<Drawer
on:close={() => {
showControls.set(false);
}}
>
<div class=" px-6 py-4 h-full">
<Controls <Controls
on:close={() => { on:close={() => {
show = false; showControls.set(false);
}} }}
{models} {models}
bind:chatFiles bind:chatFiles
bind:params bind:params
/> />
{/if} </div>
</Drawer>
{/if}
{:else if $showControls}
<div class=" absolute bottom-0 right-0 z-20 h-full pointer-events-none">
<div class="pr-4 pt-14 pb-8 w-[24rem] h-full" in:slide={{ duration: 200, axis: 'x' }}>
<div
class="w-full h-full {$showOverview
? ' '
: 'px-5 py-4 bg-white dark:shadow-lg dark:bg-gray-850 border border-gray-50 dark:border-gray-800'} rounded-lg z-50 pointer-events-auto overflow-y-auto scrollbar-hidden"
>
{#if $showCallOverlay}
<CallOverlay
bind:files
{submitPrompt}
{stopResponse}
{modelId}
{chatId}
{eventTarget}
on:close={() => {
showControls.set(false);
}}
/>
{:else if $showOverview}
<Overview
bind:history
on:close={() => {
showControls.set(false);
}}
/>
{:else}
<Controls
on:close={() => {
showControls.set(false);
}}
{models}
bind:chatFiles
bind:params
/>
{/if}
</div>
</div> </div>
</div> </div>
</div> {/if}
{/if} </SvelteFlowProvider>

View File

@ -1,23 +1,11 @@
<script lang="ts"> <script lang="ts">
import { settings } from '$lib/stores'; import { settings } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants'; import ProfileImageBase from './ProfileImageBase.svelte';
export let className = 'size-8'; export let className = 'size-8';
export let src = '';
export let src = '/user.png';
</script> </script>
<div class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}> <div class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}>
<img <ProfileImageBase {src} {className} />
crossorigin="anonymous"
src={src.startsWith(WEBUI_BASE_URL) ||
src.startsWith('https://www.gravatar.com/avatar/') ||
src.startsWith('data:') ||
src.startsWith('/')
? src
: `/user.png`}
class=" {className} object-cover rounded-full -translate-y-[1px]"
alt="profile"
draggable="false"
/>
</div> </div>

View File

@ -0,0 +1,21 @@
<script lang="ts">
import { WEBUI_BASE_URL } from '$lib/constants';
export let className = 'size-8';
export let src = `${WEBUI_BASE_URL}/static/favicon.png`;
</script>
<img
crossorigin="anonymous"
src={src === ''
? `${WEBUI_BASE_URL}/static/favicon.png`
: src.startsWith(WEBUI_BASE_URL) ||
src.startsWith('https://www.gravatar.com/avatar/') ||
src.startsWith('data:') ||
src.startsWith('/')
? src
: `/user.png`}
class=" {className} object-cover rounded-full -translate-y-[1px]"
alt="profile"
draggable="false"
/>

View File

@ -0,0 +1,143 @@
<script>
import { getContext, createEventDispatcher } from 'svelte';
import { useSvelteFlow, useNodesInitialized, useStore } from '@xyflow/svelte';
import { SvelteFlow, Controls, Background, BackgroundVariant } from '@xyflow/svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import { onMount, tick } from 'svelte';
import { writable } from 'svelte/store';
import { models, showOverview, theme, user } from '$lib/stores';
import '@xyflow/svelte/dist/style.css';
import CustomNode from './Overview/Node.svelte';
import Flow from './Overview/Flow.svelte';
import XMark from '../icons/XMark.svelte';
const { width, height } = useStore();
const { fitView, getViewport } = useSvelteFlow();
const nodesInitialized = useNodesInitialized();
export let history;
const nodes = writable([]);
const edges = writable([]);
const nodeTypes = {
custom: CustomNode
};
$: if (history) {
drawFlow();
}
const drawFlow = async () => {
const nodeList = [];
const edgeList = [];
const levelOffset = 150; // Vertical spacing between layers
const siblingOffset = 250; // Horizontal spacing between nodes at the same layer
// Map to keep track of node positions at each level
let positionMap = new Map();
// Helper function to truncate labels
function createLabel(content) {
const maxLength = 100;
return content.length > maxLength ? content.substr(0, maxLength) + '...' : content;
}
// Create nodes and map children to ensure alignment in width
let layerWidths = {}; // Track widths of each layer
Object.keys(history.messages).forEach((id) => {
const message = history.messages[id];
const level = message.parentId ? positionMap.get(message.parentId).level + 1 : 0;
if (!layerWidths[level]) layerWidths[level] = 0;
positionMap.set(id, {
id: message.id,
level,
position: layerWidths[level]++
});
});
// Adjust positions based on siblings count to centralize vertical spacing
Object.keys(history.messages).forEach((id) => {
const pos = positionMap.get(id);
const xOffset = pos.position * siblingOffset;
const y = pos.level * levelOffset;
const x = xOffset;
nodeList.push({
id: pos.id,
type: 'custom',
data: {
user: $user,
message: history.messages[id],
model: $models.find((model) => model.id === history.messages[id].model),
label: createLabel(history.messages[id].content)
},
position: { x, y }
});
// Create edges
const parentId = history.messages[id].parentId;
if (parentId) {
edgeList.push({
id: parentId + '-' + pos.id,
source: parentId,
target: pos.id,
type: 'smoothstep',
animated: true
});
}
});
await edges.set([...edgeList]);
await nodes.set([...nodeList]);
};
onMount(() => {
nodesInitialized.subscribe(async (initialized) => {
if (initialized) {
await tick();
const res = await fitView();
}
});
width.subscribe((value) => {
if (value) {
fitView();
}
});
height.subscribe((value) => {
if (value) {
fitView();
}
});
});
</script>
<div class="w-full h-full relative">
<div class=" absolute z-50 w-full flex justify-between dark:text-gray-100 px-5 py-4">
<div class=" text-lg font-medium self-center font-primary">{$i18n.t('Chat Overview')}</div>
<button
class="self-center"
on:click={() => {
dispatch('close');
showOverview.set(false);
}}
>
<XMark className="size-4" />
</button>
</div>
{#if $nodes.length > 0}
<Flow {nodes} {nodeTypes} {edges} />
{/if}
</div>

View File

@ -0,0 +1,25 @@
<script>
import { theme } from '$lib/stores';
import { Background, Controls, SvelteFlow, BackgroundVariant } from '@xyflow/svelte';
export let nodes;
export let nodeTypes;
export let edges;
</script>
<SvelteFlow
{nodes}
{nodeTypes}
{edges}
fitView
minZoom={0.001}
colorMode={$theme.includes('dark') ? 'dark' : 'light'}
nodesDraggable={false}
on:nodeclick={(event) => console.log('on node click', event.detail.node)}
oninit={() => {
console.log('Flow initialized');
}}
>
<Controls showLock={false} />
<Background variant={BackgroundVariant.Dots} />
</SvelteFlow>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import { WEBUI_BASE_URL } from '$lib/constants';
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import ProfileImageBase from '../Messages/ProfileImageBase.svelte';
type $$Props = NodeProps;
export let data: $$Props['data'];
</script>
<div
class="px-4 py-3 shadow-md rounded-xl dark:bg-black bg-white border dark:border-gray-900 w-60 h-20"
>
{#if data.message.role === 'user'}
<div class="flex w-full">
<ProfileImageBase
src={data.user?.profile_image_url ?? '/user.png'}
className={'size-5 -translate-y-[1px]'}
/>
<div class="ml-2">
<div class="text-xs font-medium">{data.user.name}</div>
<div class="text-gray-500 line-clamp-2 text-xs mt-0.5">{data.message.content}</div>
</div>
</div>
{:else}
<div class="flex w-full">
<ProfileImageBase
src={data?.model?.info?.meta?.profile_image_url ?? ''}
className={'size-5 -translate-y-[1px]'}
/>
<div class="ml-2">
<div class="text-xs font-medium">{data.model.name}</div>
<div class="text-gray-500 line-clamp-2 text-xs mt-0.5">{data.message.content}</div>
</div>
</div>
{/if}
<Handle type="target" position={Position.Top} class="w-2 rounded-full dark:bg-gray-900" />
<Handle type="source" position={Position.Bottom} class="w-2 rounded-full dark:bg-gray-900" />
</div>

View File

@ -3,6 +3,8 @@
import { flyAndScale } from '$lib/utils/transitions'; import { flyAndScale } from '$lib/utils/transitions';
import { fade, fly, slide } from 'svelte/transition'; import { fade, fly, slide } from 'svelte/transition';
const dispatch = createEventDispatcher();
export let show = false; export let show = false;
export let size = 'md'; export let size = 'md';
@ -47,6 +49,10 @@
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
} }
$: if (!show) {
dispatch('close');
}
onDestroy(() => { onDestroy(() => {
show = false; show = false;
if (modalElement) { if (modalElement) {

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '2';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<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.75H9a.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>

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '2';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z"
/>
</svg>

View File

@ -8,7 +8,7 @@
mobile, mobile,
settings, settings,
showArchivedChats, showArchivedChats,
showSettings, showControls,
showSidebar, showSidebar,
user user
} from '$lib/stores'; } from '$lib/stores';
@ -22,6 +22,7 @@
import UserMenu from './Sidebar/UserMenu.svelte'; import UserMenu from './Sidebar/UserMenu.svelte';
import MenuLines from '../icons/MenuLines.svelte'; import MenuLines from '../icons/MenuLines.svelte';
import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte'; import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte';
import Map from '../icons/Map.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -31,9 +32,7 @@
export let chat; export let chat;
export let selectedModels; export let selectedModels;
export let showModelSelector = true; export let showModelSelector = true;
export let showControls = false;
let showShareChatModal = false; let showShareChatModal = false;
let showDownloadChatModal = false; let showDownloadChatModal = false;
@ -110,7 +109,7 @@
<button <button
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition" class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={() => { on:click={() => {
showControls = !showControls; showControls.set(!$showControls);
}} }}
aria-label="Controls" aria-label="Controls"
> >

View File

@ -8,7 +8,7 @@
import { downloadChatAsPDF } from '$lib/apis/utils'; import { downloadChatAsPDF } from '$lib/apis/utils';
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import { showSettings } from '$lib/stores'; import { showOverview, showControls } from '$lib/stores';
import { flyAndScale } from '$lib/utils/transitions'; import { flyAndScale } from '$lib/utils/transitions';
import Dropdown from '$lib/components/common/Dropdown.svelte'; import Dropdown from '$lib/components/common/Dropdown.svelte';
@ -128,8 +128,9 @@
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
id="chat-overview-button" id="chat-overview-button"
on:click={() => { on:click={async () => {
shareHandler(); await showControls.set(true);
await showOverview.set(true);
}} }}
> >
<Map className=" size-4" strokeWidth="1.5" /> <Map className=" size-4" strokeWidth="1.5" />

View File

@ -40,6 +40,9 @@ export const showSidebar = writable(false);
export const showSettings = writable(false); export const showSettings = writable(false);
export const showArchivedChats = writable(false); export const showArchivedChats = writable(false);
export const showChangelog = writable(false); export const showChangelog = writable(false);
export const showControls = writable(false);
export const showOverview = writable(false);
export const showCallOverlay = writable(false); export const showCallOverlay = writable(false);
export const temporaryChatEnabled = writable(false); export const temporaryChatEnabled = writable(false);