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..987e9cff 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,9 +17,34 @@ window.location.hash = '/'; } } + + function handleToggleSidebar(): void { + if (onToggleSidebar) { + onToggleSidebar(); + } + }
+ + {#if showSidebarToggle} +
+ +
+ {/if} + +
+
${highlighted}
+
+ `; + }; + + // Inline code + renderer.codespan = function ({ text }: { text: string }) { + return `${text}`; + }; + + marked.use({ renderer }); + + /** + * Preprocess LaTeX: convert \(...\) to $...$ and \[...\] to $$...$$ + * Also protect code blocks from LaTeX processing + */ + function preprocessLaTeX(text: string): string { + // Protect code blocks + const codeBlocks: string[] = []; + let processed = text.replace(/```[\s\S]*?```|`[^`]+`/g, (match) => { + codeBlocks.push(match); + return `<>`; + }); + + // Convert \(...\) to $...$ + processed = processed.replace(/\\\((.+?)\\\)/g, '$$$1$'); + + // Convert \[...\] to $$...$$ + processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, '$$$$$1$$$$'); + + // Restore code blocks + processed = processed.replace(/<>/g, (_, index) => codeBlocks[parseInt(index)]); + + return processed; + } + + /** + * Render math expressions with KaTeX after HTML is generated + */ + function renderMath(html: string): string { + // Render display math ($$...$$) + html = html.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => { + try { + return katex.renderToString(math.trim(), { + displayMode: true, + throwOnError: false, + output: 'html' + }); + } catch { + return `$$${math}$$`; + } + }); + + // Render inline math ($...$) but avoid matching currency like $5 + html = html.replace(/\$([^\$\n]+?)\$/g, (match, math) => { + // Skip if it looks like currency ($ followed by number) + if (/^\d/.test(math.trim())) { + return match; + } + try { + return katex.renderToString(math.trim(), { + displayMode: false, + throwOnError: false, + output: 'html' + }); + } catch { + return `$${math}$`; + } + }); + + return html; + } + + function processMarkdown(text: string): string { + try { + // Preprocess LaTeX notation + const preprocessed = preprocessLaTeX(text); + // Parse markdown + let html = marked.parse(preprocessed) as string; + // Render math expressions + html = renderMath(html); + return html; + } catch (error) { + console.error('Markdown processing error:', error); + return text.replace(/\n/g, '
'); + } + } + + async function handleCopyClick(event: Event) { + const target = event.currentTarget as HTMLButtonElement; + const encodedCode = target.getAttribute('data-code'); + if (!encodedCode) return; + + const code = decodeURIComponent(encodedCode); + + try { + await navigator.clipboard.writeText(code); + // Show copied feedback + const originalHtml = target.innerHTML; + target.innerHTML = ` + + + + `; + target.classList.add('copied'); + setTimeout(() => { + target.innerHTML = originalHtml; + target.classList.remove('copied'); + }, 2000); + } catch (error) { + console.error('Failed to copy:', error); + } + } + + function setupCopyButtons() { + if (!containerRef || !browser) return; + + const buttons = containerRef.querySelectorAll('.copy-code-btn'); + for (const button of buttons) { + if (button.dataset.listenerBound !== 'true') { + button.dataset.listenerBound = 'true'; + button.addEventListener('click', handleCopyClick); + } + } + } + + $effect(() => { + if (content) { + processedHtml = processMarkdown(content); + } else { + processedHtml = ''; + } + }); + + $effect(() => { + if (containerRef && processedHtml) { + setupCopyButtons(); + } + }); + + +
+ {@html processedHtml} +
+ + diff --git a/dashboard/src/lib/components/index.ts b/dashboard/src/lib/components/index.ts index bd750839..fed749c4 100644 --- a/dashboard/src/lib/components/index.ts +++ b/dashboard/src/lib/components/index.ts @@ -4,4 +4,5 @@ export { default as ChatMessages } from './ChatMessages.svelte'; export { default as ChatAttachments } from './ChatAttachments.svelte'; export { default as ChatSidebar } from './ChatSidebar.svelte'; export { default as ModelCard } from './ModelCard.svelte'; +export { default as MarkdownContent } from './MarkdownContent.svelte'; diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts index ffeb1aa1..e0bfbad1 100644 --- a/dashboard/src/lib/stores/app.svelte.ts +++ b/dashboard/src/lib/stores/app.svelte.ts @@ -327,6 +327,8 @@ class AppStore { isTopologyMinimized = $state(false); isSidebarOpen = $state(false); // Hidden by default, shown when in chat mode debugMode = $state(false); + topologyOnlyMode = $state(false); + chatSidebarVisible = $state(true); // Shown by default private fetchInterval: ReturnType | null = null; private previewsInterval: ReturnType | null = null; @@ -337,6 +339,8 @@ class AppStore { this.startPolling(); this.loadConversationsFromStorage(); this.loadDebugModeFromStorage(); + this.loadTopologyOnlyModeFromStorage(); + this.loadChatSidebarVisibleFromStorage(); } } @@ -394,6 +398,44 @@ class AppStore { } } + private loadTopologyOnlyModeFromStorage() { + try { + const stored = localStorage.getItem('exo-topology-only-mode'); + if (stored !== null) { + this.topologyOnlyMode = stored === 'true'; + } + } catch (error) { + console.error('Failed to load topology only mode:', error); + } + } + + private saveTopologyOnlyModeToStorage() { + try { + localStorage.setItem('exo-topology-only-mode', this.topologyOnlyMode ? 'true' : 'false'); + } catch (error) { + console.error('Failed to save topology only mode:', error); + } + } + + private loadChatSidebarVisibleFromStorage() { + try { + const stored = localStorage.getItem('exo-chat-sidebar-visible'); + if (stored !== null) { + this.chatSidebarVisible = stored === 'true'; + } + } catch (error) { + console.error('Failed to load chat sidebar visibility:', error); + } + } + + private saveChatSidebarVisibleToStorage() { + try { + localStorage.setItem('exo-chat-sidebar-visible', this.chatSidebarVisible ? 'true' : 'false'); + } catch (error) { + console.error('Failed to save chat sidebar visibility:', error); + } + } + /** * Create a new conversation */ @@ -698,6 +740,34 @@ class AppStore { this.saveDebugModeToStorage(); } + getTopologyOnlyMode(): boolean { + return this.topologyOnlyMode; + } + + setTopologyOnlyMode(enabled: boolean) { + this.topologyOnlyMode = enabled; + this.saveTopologyOnlyModeToStorage(); + } + + toggleTopologyOnlyMode() { + this.topologyOnlyMode = !this.topologyOnlyMode; + this.saveTopologyOnlyModeToStorage(); + } + + getChatSidebarVisible(): boolean { + return this.chatSidebarVisible; + } + + setChatSidebarVisible(visible: boolean) { + this.chatSidebarVisible = visible; + this.saveChatSidebarVisibleToStorage(); + } + + toggleChatSidebarVisible() { + this.chatSidebarVisible = !this.chatSidebarVisible; + this.saveChatSidebarVisibleToStorage(); + } + startPolling() { this.fetchState(); this.fetchInterval = setInterval(() => this.fetchState(), 1000); @@ -888,8 +958,6 @@ class AppStore { if (lastUserIndex === -1) return; - const lastUserMessage = this.messages[lastUserIndex]; - // Remove any messages after the user message this.messages = this.messages.slice(0, lastUserIndex + 1); @@ -930,7 +998,10 @@ class AppStore { } if (!modelToUse) { - assistantMessage.content = 'Error: No model available. Please launch an instance first.'; + const idx = this.messages.findIndex(m => m.id === assistantMessage.id); + if (idx !== -1) { + this.messages[idx].content = 'Error: No model available. Please launch an instance first.'; + } this.isLoading = false; this.updateActiveConversation(); return; @@ -948,7 +1019,10 @@ class AppStore { if (!response.ok) { const errorText = await response.text(); - assistantMessage.content = `Error: ${response.status} - ${errorText}`; + const idx = this.messages.findIndex(m => m.id === assistantMessage.id); + if (idx !== -1) { + this.messages[idx].content = `Error: ${response.status} - ${errorText}`; + } this.isLoading = false; this.updateActiveConversation(); return; @@ -956,7 +1030,10 @@ class AppStore { const reader = response.body?.getReader(); if (!reader) { - assistantMessage.content = 'Error: No response stream available'; + const idx = this.messages.findIndex(m => m.id === assistantMessage.id); + if (idx !== -1) { + this.messages[idx].content = 'Error: No response stream available'; + } this.isLoading = false; this.updateActiveConversation(); return; @@ -984,9 +1061,16 @@ class AppStore { const delta = json.choices?.[0]?.delta?.content; if (delta) { fullContent += delta; - const { displayContent } = this.stripThinkingTags(fullContent); + const { displayContent, thinkingContent } = this.stripThinkingTags(fullContent); this.currentResponse = displayContent; - assistantMessage.content = displayContent; + + // Update the assistant message in place (triggers Svelte reactivity) + const idx = this.messages.findIndex(m => m.id === assistantMessage.id); + if (idx !== -1) { + this.messages[idx].content = displayContent; + this.messages[idx].thinking = thinkingContent || undefined; + } + this.persistActiveConversation(); } } catch { // Skip malformed JSON @@ -995,16 +1079,25 @@ class AppStore { } } - const { displayContent } = this.stripThinkingTags(fullContent); - assistantMessage.content = displayContent; - this.currentResponse = ''; - this.updateActiveConversation(); + // Final cleanup of the message + const { displayContent, thinkingContent } = this.stripThinkingTags(fullContent); + const idx = this.messages.findIndex(m => m.id === assistantMessage.id); + if (idx !== -1) { + this.messages[idx].content = displayContent; + this.messages[idx].thinking = thinkingContent || undefined; + } + this.persistActiveConversation(); } catch (error) { - assistantMessage.content = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; - this.updateActiveConversation(); + const idx = this.messages.findIndex(m => m.id === assistantMessage.id); + if (idx !== -1) { + this.messages[idx].content = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + this.persistActiveConversation(); } finally { this.isLoading = false; + this.currentResponse = ''; + this.updateActiveConversation(); } } @@ -1364,6 +1457,8 @@ export const lastUpdate = () => appStore.lastUpdate; export const isTopologyMinimized = () => appStore.isTopologyMinimized; export const selectedChatModel = () => appStore.selectedChatModel; export const debugMode = () => appStore.getDebugMode(); +export const topologyOnlyMode = () => appStore.getTopologyOnlyMode(); +export const chatSidebarVisible = () => appStore.getChatSidebarVisible(); // Actions export const startChat = () => appStore.startChat(); @@ -1391,5 +1486,9 @@ export const isSidebarOpen = () => appStore.isSidebarOpen; export const toggleSidebar = () => appStore.toggleSidebar(); export const toggleDebugMode = () => appStore.toggleDebugMode(); export const setDebugMode = (enabled: boolean) => appStore.setDebugMode(enabled); +export const toggleTopologyOnlyMode = () => appStore.toggleTopologyOnlyMode(); +export const setTopologyOnlyMode = (enabled: boolean) => appStore.setTopologyOnlyMode(enabled); +export const toggleChatSidebarVisible = () => appStore.toggleChatSidebarVisible(); +export const setChatSidebarVisible = (visible: boolean) => appStore.setChatSidebarVisible(visible); export const refreshState = () => appStore.fetchState(); diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte index 082d1138..432ac80b 100644 --- a/dashboard/src/routes/+page.svelte +++ b/dashboard/src/routes/+page.svelte @@ -18,6 +18,10 @@ selectedChatModel, debugMode, toggleDebugMode, + topologyOnlyMode, + toggleTopologyOnlyMode, + chatSidebarVisible, + toggleChatSidebarVisible, type DownloadProgress, type PlacementPreview } from '$lib/stores/app.svelte'; @@ -37,6 +41,8 @@ const selectedModelId = $derived(selectedPreviewModelId()); const loadingPreviews = $derived(isLoadingPreviews()); const debugEnabled = $derived(debugMode()); +const topologyOnlyEnabled = $derived(topologyOnlyMode()); +const sidebarVisible = $derived(chatSidebarVisible()); let mounted = $state(false); @@ -434,7 +440,7 @@ function toggleInstanceDownloadDetails(nodeId: string): void { return { isDownloading: false, progress: null, perNode: [] }; } - let totalBytes = 0; + let modelTotalBytes = 0; let downloadedBytes = 0; let totalSpeed = 0; let completedFiles = 0; @@ -472,7 +478,10 @@ function toggleInstanceDownloadDetails(nodeId: string): void { const progress = parseDownloadProgress(downloadPayload); if (progress) { - totalBytes += progress.totalBytes; + // Capture totalBytes from download progress (same for all nodes, don't sum) + if (modelTotalBytes === 0) { + modelTotalBytes = progress.totalBytes; + } downloadedBytes += progress.downloadedBytes; totalSpeed += progress.speed; completedFiles += progress.completedFiles; @@ -492,11 +501,11 @@ function toggleInstanceDownloadDetails(nodeId: string): void { return { isDownloading: true, progress: { - totalBytes, + totalBytes: modelTotalBytes, downloadedBytes, speed: totalSpeed, - etaMs: totalSpeed > 0 ? ((totalBytes - downloadedBytes) / totalSpeed) * 1000 : 0, - percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0, + etaMs: totalSpeed > 0 ? ((modelTotalBytes - downloadedBytes) / totalSpeed) * 1000 : 0, + percentage: modelTotalBytes > 0 ? (downloadedBytes / modelTotalBytes) * 100 : 0, completedFiles, totalFiles, files: allFiles @@ -540,7 +549,7 @@ function toggleInstanceDownloadDetails(nodeId: string): void { runnerToNode[runnerId] = nodeId; } - let totalBytes = 0; + let modelTotalBytes = 0; let downloadedBytes = 0; let totalSpeed = 0; let completedFiles = 0; @@ -576,7 +585,10 @@ function toggleInstanceDownloadDetails(nodeId: string): void { const progress = parseDownloadProgress(downloadPayload); if (progress) { - totalBytes += progress.totalBytes; + // Capture totalBytes from download progress (same for all nodes, don't sum) + if (modelTotalBytes === 0) { + modelTotalBytes = progress.totalBytes; + } downloadedBytes += progress.downloadedBytes; totalSpeed += progress.speed; completedFiles += progress.completedFiles; @@ -599,11 +611,11 @@ function toggleInstanceDownloadDetails(nodeId: string): void { return { isDownloading: true, progress: { - totalBytes, + totalBytes: modelTotalBytes, downloadedBytes, speed: totalSpeed, - etaMs: totalSpeed > 0 ? ((totalBytes - downloadedBytes) / totalSpeed) * 1000 : 0, - percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0, + etaMs: totalSpeed > 0 ? ((modelTotalBytes - downloadedBytes) / totalSpeed) * 1000 : 0, + percentage: modelTotalBytes > 0 ? (downloadedBytes / modelTotalBytes) * 100 : 0, completedFiles, totalFiles, files: allFiles @@ -618,10 +630,12 @@ function toggleInstanceDownloadDetails(nodeId: string): void { function getStatusColor(statusText: string): string { switch (statusText) { case 'FAILED': return 'text-red-400'; + case 'SHUTDOWN': return 'text-gray-400'; case 'DOWNLOADING': return 'text-blue-400'; case 'LOADING': case 'WARMING UP': - case 'WAITING': return 'text-yellow-400'; + case 'WAITING': + case 'INITIALIZING': return 'text-yellow-400'; case 'RUNNING': return 'text-teal-400'; case 'READY': case 'LOADED': return 'text-green-400'; @@ -644,12 +658,15 @@ function toggleInstanceDownloadDetails(nodeId: string): void { if (!r) return null; const [kind] = getTagged(r); const statusMap: Record = { + RunnerWaitingForInitialization: 'WaitingForInitialization', + RunnerInitializingBackend: 'InitializingBackend', RunnerWaitingForModel: 'WaitingForModel', RunnerLoading: 'Loading', RunnerLoaded: 'Loaded', RunnerWarmingUp: 'WarmingUp', RunnerReady: 'Ready', RunnerRunning: 'Running', + RunnerShutdown: 'Shutdown', RunnerFailed: 'Failed', }; return kind ? statusMap[kind] || null : null; @@ -660,12 +677,15 @@ function toggleInstanceDownloadDetails(nodeId: string): void { if (statuses.length === 0) return { statusText: 'UNKNOWN', statusClass: 'inactive' }; if (has('Failed')) return { statusText: 'FAILED', statusClass: 'failed' }; + if (has('Shutdown')) return { statusText: 'SHUTDOWN', statusClass: 'inactive' }; if (has('Loading')) return { statusText: 'LOADING', statusClass: 'starting' }; if (has('WarmingUp')) return { statusText: 'WARMING UP', statusClass: 'starting' }; if (has('Running')) return { statusText: 'RUNNING', statusClass: 'running' }; if (has('Ready')) return { statusText: 'READY', statusClass: 'loaded' }; if (has('Loaded')) return { statusText: 'LOADED', statusClass: 'loaded' }; if (has('WaitingForModel')) return { statusText: 'WAITING', statusClass: 'starting' }; + if (has('InitializingBackend')) return { statusText: 'INITIALIZING', statusClass: 'starting' }; + if (has('WaitingForInitialization')) return { statusText: 'INITIALIZING', statusClass: 'starting' }; return { statusText: 'RUNNING', statusClass: 'active' }; } @@ -1107,16 +1127,47 @@ function toggleInstanceDownloadDetails(nodeId: string): void {
- + {#if !topologyOnlyEnabled} + + {/if}
- + + {#if !topologyOnlyEnabled && sidebarVisible}
+ {/if} - {#if !chatStarted} + {#if topologyOnlyEnabled} + +
+
+ + + +
+
+ {:else if !chatStarted}
@@ -1611,13 +1662,13 @@ function toggleInstanceDownloadDetails(nodeId: string): void { in:fade={{ duration: 300, delay: 100 }} >
-
+
-
+
@@ -1655,7 +1706,7 @@ function toggleInstanceDownloadDetails(nodeId: string): void {
-

Instances

+

Instances

@@ -1701,28 +1752,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 +1784,83 @@ 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))} +
+
{f.name} - {Math.min(100, Math.max(0, f.percentage)).toFixed(1)}% + {filePercent.toFixed(1)}%
-
+
@@ -1803,27 +1869,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 @@