فهرست منبع

feat: chat overview

Timothy J. Baek 7 ماه پیش
والد
کامیت
d1dbb9a3be

+ 4 - 0
src/app.css

@@ -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;
+}

+ 6 - 7
src/lib/components/chat/Chat.svelte

@@ -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

+ 68 - 50
src/lib/components/chat/ChatControls.svelte

@@ -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>

+ 3 - 15
src/lib/components/chat/Messages/ProfileImage.svelte

@@ -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>

+ 21 - 0
src/lib/components/chat/Messages/ProfileImageBase.svelte

@@ -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"
+/>

+ 143 - 0
src/lib/components/chat/Overview.svelte

@@ -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>

+ 25 - 0
src/lib/components/chat/Overview/Flow.svelte

@@ -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>

+ 40 - 0
src/lib/components/chat/Overview/Node.svelte

@@ -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>

+ 6 - 0
src/lib/components/common/Drawer.svelte

@@ -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) {

+ 19 - 0
src/lib/components/icons/Clipboard.svelte

@@ -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>

+ 19 - 0
src/lib/components/icons/Map.svelte

@@ -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>

+ 3 - 4
src/lib/components/layout/Navbar.svelte

@@ -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"
 					>

+ 4 - 3
src/lib/components/layout/Navbar/Menu.svelte

@@ -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" />

+ 3 - 0
src/lib/stores/index.ts

@@ -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);