diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 65d23242..1768cb96 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "highlight.js": "^11.11.1", + "katex": "^0.16.27", + "marked": "^17.0.1", "mode-watcher": "^1.1.0" }, "devDependencies": { @@ -861,7 +863,6 @@ "integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -901,7 +902,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1518,7 +1518,6 @@ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1528,7 +1527,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1941,7 +1939,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -2254,6 +2251,31 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/katex": { + "version": "0.16.27", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", + "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2540,6 +2562,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mode-watcher": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", @@ -2612,7 +2646,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2800,7 +2833,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.3.tgz", "integrity": "sha512-ngKXNhNvwPzF43QqEhDOue7TQTrG09em1sd4HBxVF0Wr2gopAmdEWan+rgbdgK4fhBtSOTJO8bYU4chUG7VXZQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2945,7 +2977,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2967,7 +2998,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/dashboard/package.json b/dashboard/package.json index c9c27630..57e4be28 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -27,7 +27,8 @@ }, "dependencies": { "highlight.js": "^11.11.1", + "katex": "^0.16.27", + "marked": "^17.0.1", "mode-watcher": "^1.1.0" } } - diff --git a/dashboard/src/lib/components/ChatMessages.svelte b/dashboard/src/lib/components/ChatMessages.svelte index baaf43f7..2bbd09f8 100644 --- a/dashboard/src/lib/components/ChatMessages.svelte +++ b/dashboard/src/lib/components/ChatMessages.svelte @@ -8,89 +8,80 @@ regenerateLastResponse } from '$lib/stores/app.svelte'; import type { MessageAttachment } from '$lib/stores/app.svelte'; -import { tick, onDestroy } from 'svelte'; + import MarkdownContent from './MarkdownContent.svelte'; -interface Props { - class?: string; - scrollParent?: HTMLElement | null; -} + interface Props { + class?: string; + scrollParent?: HTMLElement | null; + } -let { class: className = '', scrollParent = null }: Props = $props(); + let { class: className = '', scrollParent = null }: Props = $props(); const messageList = $derived(messages()); const response = $derived(currentResponse()); const loading = $derived(isLoading()); -// Ref for scroll anchor at bottom -let scrollAnchorRef: HTMLDivElement | undefined = $state(); + // Scroll management - user controls scroll, show button when not at bottom + const SCROLL_THRESHOLD = 100; + let showScrollButton = $state(false); + let lastMessageCount = 0; + let containerRef: HTMLDivElement | undefined = $state(); -// Scroll management -const SCROLL_BOTTOM_THRESHOLD = 120; -let autoScrollEnabled = true; -let currentScrollEl: HTMLElement | null = null; - -function resolveScrollElement(): HTMLElement | null { - if (scrollParent) return scrollParent; - let node: HTMLElement | null = scrollAnchorRef?.parentElement as HTMLElement | null; - while (node) { - const isScrollable = node.scrollHeight > node.clientHeight + 1; - if (isScrollable) return node; - node = node.parentElement; + function getScrollContainer(): HTMLElement | null { + if (scrollParent) return scrollParent; + return containerRef?.parentElement ?? null; } - return null; -} -function handleScroll() { - if (!currentScrollEl) return; - const distanceFromBottom = currentScrollEl.scrollHeight - currentScrollEl.scrollTop - currentScrollEl.clientHeight; - const isNearBottom = distanceFromBottom < SCROLL_BOTTOM_THRESHOLD; - autoScrollEnabled = isNearBottom; -} - -function attachScrollListener() { - const nextEl = resolveScrollElement(); - if (currentScrollEl === nextEl) return; - if (currentScrollEl) { - currentScrollEl.removeEventListener('scroll', handleScroll); + function isNearBottom(el: HTMLElement): boolean { + return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; } - currentScrollEl = nextEl; - if (currentScrollEl) { - currentScrollEl.addEventListener('scroll', handleScroll); - // Initialize state based on current position - handleScroll(); - } -} -onDestroy(() => { - if (currentScrollEl) { - currentScrollEl.removeEventListener('scroll', handleScroll); - } -}); - -$effect(() => { - // Re-evaluate scroll container if prop changes or after mount - scrollParent; - attachScrollListener(); -}); - -// Auto-scroll to bottom when messages change or response updates, but only if user is near bottom -$effect(() => { - // Track these values to trigger effect - const _ = messageList.length; - const __ = response; - const ___ = loading; - - tick().then(() => { - const el = currentScrollEl ?? resolveScrollElement(); - if (!el || !scrollAnchorRef) return; - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - const isNearBottom = distanceFromBottom < SCROLL_BOTTOM_THRESHOLD; - if (autoScrollEnabled || isNearBottom) { - scrollAnchorRef.scrollIntoView({ behavior: 'smooth', block: 'end' }); - autoScrollEnabled = true; + function scrollToBottom() { + const el = getScrollContainer(); + if (el) { + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); } + } + + function updateScrollButtonVisibility() { + const el = getScrollContainer(); + if (!el) return; + showScrollButton = !isNearBottom(el); + } + + // Attach scroll listener + $effect(() => { + const el = scrollParent ?? containerRef?.parentElement; + if (!el) return; + + el.addEventListener('scroll', updateScrollButtonVisibility, { passive: true }); + // Initial check + updateScrollButtonVisibility(); + return () => el.removeEventListener('scroll', updateScrollButtonVisibility); + }); + + // Auto-scroll when user sends a new message + $effect(() => { + const count = messageList.length; + if (count > lastMessageCount) { + const el = getScrollContainer(); + if (el) { + requestAnimationFrame(() => { + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + }); + } + } + lastMessageCount = count; + }); + + // Update scroll button visibility when content changes + $effect(() => { + // Track response to trigger re-check during streaming + const _ = response; + + // Small delay to let DOM update + requestAnimationFrame(() => updateScrollButtonVisibility()); }); -}); // Edit state let editingMessageId = $state(null); @@ -231,7 +222,7 @@ function isThinkingExpanded(messageId: string): boolean {
{#each messageList as message (message.id)}
-
+
{#if message.role === 'assistant'}
@@ -305,7 +296,7 @@ function isThinkingExpanded(messageId: string): boolean { {:else}
+ : 'command-panel rounded-lg rounded-tl-sm border-l-2 border-l-exo-yellow/50 block w-full'}"> {#if message.role === 'user'} @@ -331,7 +322,7 @@ function isThinkingExpanded(messageId: string): boolean { {/if} {#if message.content} -
+
{message.content}
{/if} @@ -360,7 +351,7 @@ function isThinkingExpanded(messageId: string): boolean { Thinking... - + {isThinkingExpanded(message.id) ? 'HIDE' : 'SHOW'} @@ -374,8 +365,8 @@ function isThinkingExpanded(messageId: string): boolean { {/if}
{/if} -
- {message.content || (loading ? response : '')} +
+ {#if loading && !message.content} {/if} @@ -457,6 +448,20 @@ function isThinkingExpanded(messageId: string): boolean {
{/if} - -
+ +
+ + + {#if showScrollButton} + + {/if}
diff --git a/dashboard/src/lib/components/ChatSidebar.svelte b/dashboard/src/lib/components/ChatSidebar.svelte index 87e06059..d369819d 100644 --- a/dashboard/src/lib/components/ChatSidebar.svelte +++ b/dashboard/src/lib/components/ChatSidebar.svelte @@ -10,7 +10,9 @@ import { clearChat, instances, debugMode, - toggleDebugMode + toggleDebugMode, + topologyOnlyMode, + toggleTopologyOnlyMode } from '$lib/stores/app.svelte'; interface Props { @@ -23,6 +25,7 @@ import { const activeId = $derived(activeConversationId()); const instanceData = $derived(instances()); const debugEnabled = $derived(debugMode()); +const topologyOnlyEnabled = $derived(topologyOnlyMode()); let searchQuery = $state(''); let editingId = $state(null); @@ -424,6 +427,19 @@ const debugEnabled = $derived(debugMode());
{conversationList.length} CONVERSATION{conversationList.length !== 1 ? 'S' : ''}
+
diff --git a/dashboard/src/lib/components/HeaderNav.svelte b/dashboard/src/lib/components/HeaderNav.svelte index 4ec770d6..2e1a3c9d 100644 --- a/dashboard/src/lib/components/HeaderNav.svelte +++ b/dashboard/src/lib/components/HeaderNav.svelte @@ -3,6 +3,9 @@ export let showHome = true; export let onHome: (() => void) | null = null; + export let showSidebarToggle = false; + export let sidebarVisible = true; + export let onToggleSidebar: (() => void) | null = null; function handleHome(): void { if (onHome) { @@ -14,13 +17,38 @@ window.location.hash = '/'; } } + + function handleToggleSidebar(): void { + if (onToggleSidebar) { + onToggleSidebar(); + } + }
+ + {#if showSidebarToggle} +
+ +
+ {/if} + +
+
+ {:else if !chatStarted}
@@ -1300,14 +1355,15 @@ function toggleInstanceDownloadDetails(nodeId: string): void { {:else} {#each nodeProg.progress.files as f} {@const filePercent = Math.min(100, Math.max(0, f.percentage ?? 0))} + {@const isFileComplete = filePercent >= 100}
{f.name} - {filePercent.toFixed(1)}% + {filePercent.toFixed(1)}%
@@ -1611,13 +1667,13 @@ function toggleInstanceDownloadDetails(nodeId: string): void { in:fade={{ duration: 300, delay: 100 }} >
-
+
-
+
@@ -1655,7 +1711,7 @@ function toggleInstanceDownloadDetails(nodeId: string): void {
-

Instances

+

Instances

@@ -1701,28 +1757,28 @@ function toggleInstanceDownloadDetails(nodeId: string): void {
- {id.slice(0, 8).toUpperCase()} + {id.slice(0, 8).toUpperCase()}
-
{getInstanceModelId(instance)}
+
{getInstanceModelId(instance)}
Strategy: {instanceInfo.sharding} ({instanceInfo.instanceType})
{#if instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model'} Hugging Face - + @@ -1733,68 +1789,84 @@ function toggleInstanceDownloadDetails(nodeId: string): void {
{instanceInfo.nodeNames.join(', ')}
{/if} {#if debugEnabled && instanceConnections.length > 0} -
- {#each instanceConnections as conn} -
- {conn.from} -> {conn.to}: {conn.ip} - ({conn.ifaceLabel}) -
- {/each} +
+ {#each instanceConnections as conn} +
+ {conn.from} -> {conn.to}: {conn.ip} + ({conn.ifaceLabel}) +
+ {/each} +
+ {/if} + + + {#if downloadInfo.isDownloading && downloadInfo.progress} +
+
+ {downloadInfo.progress.percentage.toFixed(1)}% + {formatBytes(downloadInfo.progress.downloadedBytes)}/{formatBytes(downloadInfo.progress.totalBytes)}
- {/if} - - - {#if downloadInfo.isDownloading && downloadInfo.progress} -
-
- {downloadInfo.progress.percentage.toFixed(1)}% - {formatBytes(downloadInfo.progress.downloadedBytes)}/{formatBytes(downloadInfo.progress.totalBytes)} -
-
-
-
-
- {formatSpeed(downloadInfo.progress.speed)} - ETA: {formatEta(downloadInfo.progress.etaMs)} - {downloadInfo.progress.completedFiles}/{downloadInfo.progress.totalFiles} files -
+
+
- {#if downloadInfo.perNode.length > 0} -
- {#each downloadInfo.perNode as nodeProg} -
-
+
+ {formatSpeed(downloadInfo.progress.speed)} + ETA: {formatEta(downloadInfo.progress.etaMs)} + {downloadInfo.progress.completedFiles}/{downloadInfo.progress.totalFiles} files +
+
+ {#if downloadInfo.perNode.length > 0} +
+ {#each downloadInfo.perNode as nodeProg} + {@const nodePercent = Math.min(100, Math.max(0, nodeProg.progress.percentage))} + {@const isExpanded = instanceDownloadExpandedNodes.has(nodeProg.nodeId)} +
+ + + {#if isExpanded} +
+ {#if nodeProg.progress.files.length === 0} +
No file details reported.
+ {:else} + {#each nodeProg.progress.files as f} + {@const filePercent = Math.min(100, Math.max(0, f.percentage ?? 0))} + {@const isFileComplete = filePercent >= 100} +
+
{f.name} - {Math.min(100, Math.max(0, f.percentage)).toFixed(1)}% + {filePercent.toFixed(1)}%
-
+
@@ -1803,27 +1875,17 @@ function toggleInstanceDownloadDetails(nodeId: string): void {
{/each} -
- {/if} - {#if completedFiles.length > 0} -
- {#each completedFiles as f} -
- {f.name} - 100% -
- {/each} -
- {/if} + {/if} +
{/if} -
- {/each} -
- {/if} -
DOWNLOADING
- {:else} -
{downloadInfo.statusText}
+
+ {/each} +
{/if} +
DOWNLOADING
+ {:else} +
{downloadInfo.statusText}
+ {/if}
diff --git a/dashboard/src/routes/downloads/+page.svelte b/dashboard/src/routes/downloads/+page.svelte index 81e29ed9..9ee249a1 100644 --- a/dashboard/src/routes/downloads/+page.svelte +++ b/dashboard/src/routes/downloads/+page.svelte @@ -345,13 +345,19 @@
-
{model.prettyName ?? model.modelId}
-
- {model.modelId} -
-
- {formatBytes(model.downloadedBytes)} / {formatBytes(model.totalBytes)} -
+
{model.prettyName ?? model.modelId}
+
{model.modelId}
+ {#if model.status !== 'completed'} +
+ {formatBytes(model.downloadedBytes)} / {formatBytes(model.totalBytes)} +
+ {/if}
@@ -426,14 +432,14 @@