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;
@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,
user,
socket,
showControls,
showCallOverlay,
currentChatPage,
temporaryChatEnabled
@ -70,7 +71,6 @@
let loaded = false;
const eventTarget = new EventTarget();
let showControls = false;
let stopResponseFlag = false;
let autoScroll = true;
let processing = '';
@ -1703,7 +1703,6 @@
{title}
bind:selectedModels
bind:showModelSelector
bind:showControls
shareEnabled={messages.length > 0}
{chat}
{initNewChat}
@ -1713,7 +1712,7 @@
<div
class="absolute top-[4.25rem] w-full {$showSidebar
? '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">
{#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=" 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]'
: ''}"
id="messages-container"
@ -1770,7 +1769,7 @@
</div>
</div>
<div class={showControls ? 'lg:pr-[24rem]' : ''}>
<div class={$showControls ? 'lg:pr-[24rem]' : ''}>
<MessageInput
bind:files
bind:prompt
@ -1791,7 +1790,7 @@
{submitPrompt}
{stopResponse}
on:call={() => {
showControls = true;
showControls.set(true);
}}
/>
</div>
@ -1807,7 +1806,7 @@
}
return a;
}, [])}
bind:show={showControls}
bind:history
bind:chatFiles
bind:params
bind:files

View File

@ -1,14 +1,17 @@
<script lang="ts">
import { SvelteFlowProvider } from '@xyflow/svelte';
import { slide } from 'svelte/transition';
import { onMount } from 'svelte';
import { mobile, showControls, showCallOverlay, showOverview } from '$lib/stores';
import Modal from '../common/Modal.svelte';
import Controls from './Controls/Controls.svelte';
import { onMount } from 'svelte';
import { mobile, showCallOverlay } from '$lib/stores';
import CallOverlay from './MessageInput/CallOverlay.svelte';
import Drawer from '../common/Drawer.svelte';
import Overview from './Overview.svelte';
export let show = false;
export let history;
export let models = [];
export let chatId = null;
@ -44,46 +47,13 @@
});
</script>
{#if !largeScreen}
{#if $showCallOverlay}
<div class=" absolute w-full h-screen max-h-[100dvh] flex z-[999] overflow-hidden">
<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}
<SvelteFlowProvider>
{#if !largeScreen}
{#if $showCallOverlay}
<div class=" absolute w-full h-screen max-h-[100dvh] flex z-[999] overflow-hidden">
<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}
@ -92,20 +62,68 @@
{chatId}
{eventTarget}
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
on:close={() => {
show = false;
showControls.set(false);
}}
{models}
bind:chatFiles
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>
{/if}
{/if}
</SvelteFlowProvider>

View File

@ -1,23 +1,11 @@
<script lang="ts">
import { settings } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import ProfileImageBase from './ProfileImageBase.svelte';
export let className = 'size-8';
export let src = '/user.png';
export let src = '';
</script>
<div class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'}`}>
<img
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"
/>
<ProfileImageBase {src} {className} />
</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 { fade, fly, slide } from 'svelte/transition';
const dispatch = createEventDispatcher();
export let show = false;
export let size = 'md';
@ -47,6 +49,10 @@
document.body.style.overflow = 'unset';
}
$: if (!show) {
dispatch('close');
}
onDestroy(() => {
show = false;
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,
settings,
showArchivedChats,
showSettings,
showControls,
showSidebar,
user
} from '$lib/stores';
@ -22,6 +22,7 @@
import UserMenu from './Sidebar/UserMenu.svelte';
import MenuLines from '../icons/MenuLines.svelte';
import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte';
import Map from '../icons/Map.svelte';
const i18n = getContext('i18n');
@ -31,9 +32,7 @@
export let chat;
export let selectedModels;
export let showModelSelector = true;
export let showControls = false;
let showShareChatModal = false;
let showDownloadChatModal = false;
@ -110,7 +109,7 @@
<button
class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={() => {
showControls = !showControls;
showControls.set(!$showControls);
}}
aria-label="Controls"
>

View File

@ -8,7 +8,7 @@
import { downloadChatAsPDF } from '$lib/apis/utils';
import { copyToClipboard } from '$lib/utils';
import { showSettings } from '$lib/stores';
import { showOverview, showControls } from '$lib/stores';
import { flyAndScale } from '$lib/utils/transitions';
import Dropdown from '$lib/components/common/Dropdown.svelte';
@ -128,8 +128,9 @@
<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"
id="chat-overview-button"
on:click={() => {
shareHandler();
on:click={async () => {
await showControls.set(true);
await showOverview.set(true);
}}
>
<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 showArchivedChats = writable(false);
export const showChangelog = writable(false);
export const showControls = writable(false);
export const showOverview = writable(false);
export const showCallOverlay = writable(false);
export const temporaryChatEnabled = writable(false);