diff --git a/app/EXO/EXO/EXOApp.swift b/app/EXO/EXO/EXOApp.swift index a5862d17..2ba2c9e8 100644 --- a/app/EXO/EXO/EXOApp.swift +++ b/app/EXO/EXO/EXOApp.swift @@ -45,8 +45,8 @@ struct EXOApp: App { let thunderboltBridge = ThunderboltBridgeService(clusterStateService: service) _thunderboltBridgeService = StateObject(wrappedValue: thunderboltBridge) enableLaunchAtLoginIfNeeded() - // Remove old LaunchDaemon components if they exist (from previous versions) - cleanupLegacyNetworkSetup() + // Install LaunchDaemon to disable Thunderbolt Bridge on startup (prevents network loops) + NetworkSetupHelper.promptAndInstallIfNeeded() // Check local network access periodically (warning disappears when user grants permission) localNetwork.startPeriodicChecking(interval: 10) controller.scheduleLaunch(after: 15) @@ -136,36 +136,6 @@ struct EXOApp: App { } } - private func cleanupLegacyNetworkSetup() { - guard NetworkSetupHelper.hasInstalledComponents() else { return } - // Dispatch async to ensure app is ready before showing alert - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "EXO Network Configuration" - alert.informativeText = - "EXO needs to configure local network discovery on your device. This requires granting permission once." - alert.alertStyle = .informational - alert.addButton(withTitle: "Continue") - alert.addButton(withTitle: "Later") - - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { - Logger().info("User deferred legacy network setup cleanup") - return - } - - do { - try NetworkSetupHelper.uninstall() - Logger().info("Cleaned up legacy network setup components") - } catch { - // Non-fatal: user may have cancelled admin prompt or cleanup may have - // partially succeeded. The app will continue normally. - Logger().warning( - "Could not clean up legacy network setup (non-fatal): \(error.localizedDescription)" - ) - } - } - } } /// Helper for managing EXO's launch-at-login registration diff --git a/app/EXO/EXO/Services/NetworkSetupHelper.swift b/app/EXO/EXO/Services/NetworkSetupHelper.swift index 2bb3ddc3..f5b8ad1c 100644 --- a/app/EXO/EXO/Services/NetworkSetupHelper.swift +++ b/app/EXO/EXO/Services/NetworkSetupHelper.swift @@ -11,6 +11,68 @@ enum NetworkSetupHelper { private static let legacyScriptDestination = "/Library/Application Support/EXO/disable_bridge_enable_dhcp.sh" private static let plistDestination = "/Library/LaunchDaemons/io.exo.networksetup.plist" + private static let requiredStartInterval: Int = 1786 + + private static let setupScript = """ + #!/usr/bin/env bash + + set -euo pipefail + + PREFS="/Library/Preferences/SystemConfiguration/preferences.plist" + + # Remove bridge0 interface + ifconfig bridge0 &>/dev/null && { + ifconfig bridge0 | grep -q 'member' && { + ifconfig bridge0 | awk '/member/ {print $2}' | xargs -n1 ifconfig bridge0 deletem 2>/dev/null || true + } + ifconfig bridge0 destroy 2>/dev/null || true + } + + # Remove Thunderbolt Bridge from VirtualNetworkInterfaces in preferences.plist + /usr/libexec/PlistBuddy -c "Delete :VirtualNetworkInterfaces:Bridge:bridge0" "$PREFS" 2>/dev/null || true + + networksetup -listnetworkservices | grep -q "Thunderbolt Bridge" && { + networksetup -setnetworkserviceenabled "Thunderbolt Bridge" off + } || true + """ + + /// Prompts user and installs the LaunchDaemon if not already installed. + /// Shows an alert explaining what will be installed before requesting admin privileges. + static func promptAndInstallIfNeeded() { + // Use .utility priority to match NSAppleScript's internal QoS and avoid priority inversion + Task.detached(priority: .utility) { + // If already correctly installed, skip + if daemonAlreadyInstalled() { + return + } + + // Show alert on main thread + let shouldInstall = await MainActor.run { + let alert = NSAlert() + alert.messageText = "EXO Network Configuration" + alert.informativeText = + "EXO needs to install a system service to automatically disable Thunderbolt Bridge on startup. This prevents network loops when connecting multiple Macs via Thunderbolt.\n\nYou will be prompted for your administrator password." + alert.alertStyle = .informational + alert.addButton(withTitle: "Install") + alert.addButton(withTitle: "Not Now") + return alert.runModal() == .alertFirstButtonReturn + } + + guard shouldInstall else { + logger.info("User deferred network setup daemon installation") + return + } + + do { + try installLaunchDaemon() + logger.info("Network setup launch daemon installed and started") + } catch { + logger.error( + "Network setup launch daemon failed: \(error.localizedDescription, privacy: .public)" + ) + } + } + } /// Removes all EXO network setup components from the system. /// This includes the LaunchDaemon, scripts, logs, and network location. @@ -30,6 +92,100 @@ enum NetworkSetupHelper { return scriptExists || legacyScriptExists || plistExists } + private static func daemonAlreadyInstalled() -> Bool { + let manager = FileManager.default + let scriptExists = manager.fileExists(atPath: scriptDestination) + let plistExists = manager.fileExists(atPath: plistDestination) + guard scriptExists, plistExists else { return false } + guard + let installedScript = try? String(contentsOfFile: scriptDestination, encoding: .utf8), + installedScript.trimmingCharacters(in: .whitespacesAndNewlines) + == setupScript.trimmingCharacters(in: .whitespacesAndNewlines) + else { + return false + } + guard + let data = try? Data(contentsOf: URL(fileURLWithPath: plistDestination)), + let plist = try? PropertyListSerialization.propertyList( + from: data, options: [], format: nil) as? [String: Any] + else { + return false + } + guard + let interval = plist["StartInterval"] as? Int, + interval == requiredStartInterval + else { + return false + } + if let programArgs = plist["ProgramArguments"] as? [String], + programArgs.contains(scriptDestination) == false + { + return false + } + return true + } + + private static func installLaunchDaemon() throws { + let installerScript = makeInstallerScript() + try runShellAsAdmin(installerScript) + } + + private static func makeInstallerScript() -> String { + """ + set -euo pipefail + + LABEL="\(daemonLabel)" + SCRIPT_DEST="\(scriptDestination)" + LEGACY_SCRIPT_DEST="\(legacyScriptDestination)" + PLIST_DEST="\(plistDestination)" + LOG_OUT="/var/log/\(daemonLabel).log" + LOG_ERR="/var/log/\(daemonLabel).err.log" + + # First, completely remove any existing installation + launchctl bootout system/"$LABEL" 2>/dev/null || true + rm -f "$PLIST_DEST" + rm -f "$SCRIPT_DEST" + rm -f "$LEGACY_SCRIPT_DEST" + rm -f "$LOG_OUT" "$LOG_ERR" + + # Install fresh + mkdir -p "$(dirname "$SCRIPT_DEST")" + + cat > "$SCRIPT_DEST" <<'EOF_SCRIPT' + \(setupScript) + EOF_SCRIPT + chmod 755 "$SCRIPT_DEST" + + cat > "$PLIST_DEST" <<'EOF_PLIST' + + + + + Label + \(daemonLabel) + ProgramArguments + + /bin/bash + \(scriptDestination) + + StartInterval + \(requiredStartInterval) + RunAtLoad + + StandardOutPath + /var/log/\(daemonLabel).log + StandardErrorPath + /var/log/\(daemonLabel).err.log + + + EOF_PLIST + + launchctl bootstrap system "$PLIST_DEST" + launchctl enable system/"$LABEL" + launchctl kickstart -k system/"$LABEL" + """ + } + private static func makeUninstallScript() -> String { """ set -euo pipefail diff --git a/dashboard/parts.nix b/dashboard/parts.nix index 487078d5..8edcc6b7 100644 --- a/dashboard/parts.nix +++ b/dashboard/parts.nix @@ -3,6 +3,45 @@ perSystem = { pkgs, lib, ... }: let + # Stub source with lockfiles and minimal files for build to succeed + # This allows prettier-svelte to avoid rebuilding when dashboard source changes + dashboardStubSrc = pkgs.runCommand "dashboard-stub-src" { } '' + mkdir -p $out + cp ${inputs.self}/dashboard/package.json $out/ + cp ${inputs.self}/dashboard/package-lock.json $out/ + # Minimal files so vite build succeeds (produces empty output) + echo '' > $out/index.html + mkdir -p $out/src + touch $out/src/app.html + ''; + + # Deps-only build using stub source (for prettier-svelte) + # Only rebuilds when package.json or package-lock.json change + dashboardDeps = inputs.dream2nix.lib.evalModules { + packageSets.nixpkgs = pkgs; + modules = [ + ./dashboard.nix + { + paths.projectRoot = inputs.self; + paths.projectRootFile = "flake.nix"; + paths.package = inputs.self + "/dashboard"; + } + { + deps.dashboardSrc = lib.mkForce dashboardStubSrc; + } + # Override build phases to skip the actual build - just need node_modules + { + mkDerivation = { + buildPhase = lib.mkForce "true"; + installPhase = lib.mkForce '' + runHook preInstall + runHook postInstall + ''; + }; + } + ]; + }; + # Filter source to only include dashboard directory dashboardSrc = lib.cleanSourceWith { src = inputs.self; @@ -42,11 +81,12 @@ ''; # Prettier with svelte plugin for treefmt + # Uses dashboardDeps instead of dashboardFull to avoid rebuilding on source changes packages.prettier-svelte = pkgs.writeShellScriptBin "prettier-svelte" '' - export NODE_PATH="${dashboardFull}/lib/node_modules/exo-dashboard/node_modules" + export NODE_PATH="${dashboardDeps}/lib/node_modules/exo-dashboard/node_modules" exec ${pkgs.nodejs}/bin/node \ - ${dashboardFull}/lib/node_modules/exo-dashboard/node_modules/prettier/bin/prettier.cjs \ - --plugin "${dashboardFull}/lib/node_modules/exo-dashboard/node_modules/prettier-plugin-svelte/plugin.js" \ + ${dashboardDeps}/lib/node_modules/exo-dashboard/node_modules/prettier/bin/prettier.cjs \ + --plugin "${dashboardDeps}/lib/node_modules/exo-dashboard/node_modules/prettier-plugin-svelte/plugin.js" \ "$@" ''; }; diff --git a/dashboard/src/lib/components/ChatForm.svelte b/dashboard/src/lib/components/ChatForm.svelte index 42a9bb4c..6801287d 100644 --- a/dashboard/src/lib/components/ChatForm.svelte +++ b/dashboard/src/lib/components/ChatForm.svelte @@ -89,7 +89,10 @@ const isImageModel = $derived(() => { if (!currentModel) return false; - return modelSupportsTextToImage(currentModel); + return ( + modelSupportsTextToImage(currentModel) || + modelSupportsImageEditing(currentModel) + ); }); const isEditOnlyWithoutImage = $derived( @@ -646,6 +649,23 @@ EDIT + {:else if isEditOnlyWithoutImage} + + + + + EDIT + {:else if isImageModel()} = 1) { + setImageGenerationParams({ numImages: num }); + } + } + } + + function handleStreamChange(enabled: boolean) { + setImageGenerationParams({ stream: enabled }); + } + + function handlePartialImagesChange(event: Event) { + const input = event.target as HTMLInputElement; + const value = input.value.trim(); + if (value === "") { + setImageGenerationParams({ partialImages: 0 }); + } else { + const num = parseInt(value, 10); + if (!isNaN(num) && num >= 0) { + setImageGenerationParams({ partialImages: num }); + } + } + } + function clearSteps() { setImageGenerationParams({ numInferenceSteps: null }); } @@ -134,90 +164,92 @@
- -
- SIZE: -
- -
- + -
-
- - {#if isSizeDropdownOpen} - - - - -
-
- {#each sizeOptions as size} - - {/each} + {params.size} + +
+ + +
- {/if} -
+ + {#if isSizeDropdownOpen} + + + + +
+
+ {#each sizeOptions as size} + + {/each} +
+
+ {/if} +
+ {/if}
@@ -325,6 +357,59 @@
+ + {#if !isEditMode} +
+ IMAGES: + +
+ {/if} + + +
+ STREAM: + +
+ + + {#if params.stream} +
+ PARTIALS: + +
+ {/if} + {#if isEditMode}
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts index d1aff1a1..9e3198ae 100644 --- a/dashboard/src/lib/stores/app.svelte.ts +++ b/dashboard/src/lib/stores/app.svelte.ts @@ -216,6 +216,8 @@ export interface Message { attachments?: MessageAttachment[]; ttftMs?: number; // Time to first token in ms (for assistant messages) tps?: number; // Tokens per second (for assistant messages) + requestType?: "chat" | "image-generation" | "image-editing"; + sourceImageDataUrl?: string; // For image editing regeneration } export interface Conversation { @@ -238,6 +240,10 @@ export interface ImageGenerationParams { size: "512x512" | "768x768" | "1024x1024" | "1024x768" | "768x1024"; quality: "low" | "medium" | "high"; outputFormat: "png" | "jpeg"; + numImages: number; + // Streaming params + stream: boolean; + partialImages: number; // Advanced params seed: number | null; numInferenceSteps: number | null; @@ -257,6 +263,9 @@ const DEFAULT_IMAGE_PARAMS: ImageGenerationParams = { size: "1024x1024", quality: "medium", outputFormat: "png", + numImages: 1, + stream: true, + partialImages: 3, seed: null, numInferenceSteps: null, guidance: null, @@ -940,6 +949,108 @@ class AppStore { this.updateActiveConversation(); } + /** + * Update a message in a specific conversation by ID. + * Returns false if conversation or message not found. + */ + private updateConversationMessage( + conversationId: string, + messageId: string, + updater: (message: Message) => void, + ): boolean { + const conversation = this.conversations.find( + (c) => c.id === conversationId, + ); + if (!conversation) return false; + + const message = conversation.messages.find((m) => m.id === messageId); + if (!message) return false; + + updater(message); + return true; + } + + /** + * Sync this.messages from the target conversation if it matches the active conversation. + */ + private syncActiveMessagesIfNeeded(conversationId: string): void { + if (this.activeConversationId === conversationId) { + const conversation = this.conversations.find( + (c) => c.id === conversationId, + ); + if (conversation) { + this.messages = [...conversation.messages]; + } + } + } + + /** + * Check if a conversation still exists. + */ + private conversationExists(conversationId: string): boolean { + return this.conversations.some((c) => c.id === conversationId); + } + + /** + * Persist a specific conversation to storage. + */ + private persistConversation(conversationId: string, throttleMs = 400): void { + const now = Date.now(); + if (now - this.lastConversationPersistTs < throttleMs) return; + this.lastConversationPersistTs = now; + + const conversation = this.conversations.find( + (c) => c.id === conversationId, + ); + if (conversation) { + conversation.updatedAt = Date.now(); + + // Auto-generate name from first user message if still has default name + if (conversation.name.startsWith("Chat ")) { + const firstUserMsg = conversation.messages.find( + (m) => m.role === "user" && m.content.trim(), + ); + if (firstUserMsg) { + let content = firstUserMsg.content + .replace(/\[File:.*?\][\s\S]*?```[\s\S]*?```/g, "") + .trim(); + + if (content) { + const preview = content.slice(0, 50); + conversation.name = + preview.length < content.length ? preview + "..." : preview; + } + } + } + + this.saveConversationsToStorage(); + } + } + + /** + * Add a message directly to a specific conversation. + * Returns the message if added, null if conversation not found. + */ + private addMessageToConversation( + conversationId: string, + role: "user" | "assistant", + content: string, + ): Message | null { + const conversation = this.conversations.find( + (c) => c.id === conversationId, + ); + if (!conversation) return null; + + const message: Message = { + id: generateUUID(), + role, + content, + timestamp: Date.now(), + }; + conversation.messages.push(message); + return message; + } + /** * Toggle sidebar visibility */ @@ -1263,15 +1374,71 @@ class AppStore { if (lastUserIndex === -1) return; - // Remove any messages after the user message - this.messages = this.messages.slice(0, lastUserIndex + 1); + const lastUserMessage = this.messages[lastUserIndex]; + const requestType = lastUserMessage.requestType || "chat"; + const prompt = lastUserMessage.content; + + // Remove messages after user message (including the user message for image requests + // since generateImage/editImage will re-add it) + this.messages = this.messages.slice(0, lastUserIndex); + + switch (requestType) { + case "image-generation": + await this.generateImage(prompt); + break; + case "image-editing": + if (lastUserMessage.sourceImageDataUrl) { + await this.editImage(prompt, lastUserMessage.sourceImageDataUrl); + } else { + // Can't regenerate edit without source image - restore user message and show error + this.messages.push(lastUserMessage); + const errorMessage = this.addMessage("assistant", ""); + const idx = this.messages.findIndex((m) => m.id === errorMessage.id); + if (idx !== -1) { + this.messages[idx].content = + "Error: Cannot regenerate image edit - source image not found"; + } + this.updateActiveConversation(); + } + break; + case "chat": + default: + // Restore the user message for chat regeneration + this.messages.push(lastUserMessage); + await this.regenerateChatCompletion(); + break; + } + } + + /** + * Helper method to regenerate a chat completion response + */ + private async regenerateChatCompletion(): Promise { + // Capture the target conversation ID at the start of the request + const targetConversationId = this.activeConversationId; + if (!targetConversationId) return; + + const targetConversation = this.conversations.find( + (c) => c.id === targetConversationId, + ); + if (!targetConversation) return; - // Resend the message to get a new response this.isLoading = true; this.currentResponse = ""; - // Create placeholder for assistant message - const assistantMessage = this.addMessage("assistant", ""); + // Create placeholder for assistant message directly in target conversation + const assistantMessage = this.addMessageToConversation( + targetConversationId, + "assistant", + "", + ); + if (!assistantMessage) { + this.isLoading = false; + return; + } + + // Sync to this.messages if viewing the target conversation + this.syncActiveMessagesIfNeeded(targetConversationId); try { const systemPrompt = { @@ -1282,41 +1449,25 @@ class AppStore { const apiMessages = [ systemPrompt, - ...this.messages.slice(0, -1).map((m) => { + ...targetConversation.messages.slice(0, -1).map((m) => { return { role: m.role, content: m.content }; }), ]; // Determine which model to use - let modelToUse = this.selectedChatModel; + const modelToUse = this.getModelForRequest(); if (!modelToUse) { - const firstInstanceKey = Object.keys(this.instances)[0]; - if (firstInstanceKey) { - const instance = this.instances[firstInstanceKey] as - | Record - | undefined; - if (instance) { - const keys = Object.keys(instance); - if (keys.length === 1) { - const inst = instance[keys[0]] as - | { shardAssignments?: { modelId?: string } } - | undefined; - modelToUse = inst?.shardAssignments?.modelId || ""; - } - } - } - } - - if (!modelToUse) { - const idx = this.messages.findIndex( - (m) => m.id === assistantMessage.id, + this.updateConversationMessage( + targetConversationId, + assistantMessage.id, + (msg) => { + msg.content = + "Error: No model available. Please launch an instance first."; + }, ); - if (idx !== -1) { - this.messages[idx].content = - "Error: No model available. Please launch an instance first."; - } + this.syncActiveMessagesIfNeeded(targetConversationId); this.isLoading = false; - this.updateActiveConversation(); + this.saveConversationsToStorage(); return; } @@ -1332,94 +1483,76 @@ class AppStore { if (!response.ok) { const errorText = await response.text(); - 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; + throw new Error(`${response.status} - ${errorText}`); } const reader = response.body?.getReader(); if (!reader) { - 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; + throw new Error("No response stream available"); } - const decoder = new TextDecoder(); - let fullContent = ""; - let partialLine = ""; + let streamedContent = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; + interface ChatCompletionChunk { + choices?: Array<{ delta?: { content?: string } }>; + } - const chunk = decoder.decode(value, { stream: true }); - const lines = (partialLine + chunk).split("\n"); - partialLine = lines.pop() || ""; + await this.parseSSEStream( + reader, + targetConversationId, + (parsed) => { + const delta = parsed.choices?.[0]?.delta?.content; + if (delta) { + streamedContent += delta; + const { displayContent, thinkingContent } = + this.stripThinkingTags(streamedContent); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed === "data: [DONE]") continue; - - if (trimmed.startsWith("data: ")) { - try { - const json = JSON.parse(trimmed.slice(6)); - const delta = json.choices?.[0]?.delta?.content; - if (delta) { - fullContent += delta; - const { displayContent, thinkingContent } = - this.stripThinkingTags(fullContent); - this.currentResponse = 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 + // Only update currentResponse if target conversation is active + if (this.activeConversationId === targetConversationId) { + this.currentResponse = displayContent; } - } - } - } - // 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; + // Update the assistant message in the target conversation + this.updateConversationMessage( + targetConversationId, + assistantMessage.id, + (msg) => { + msg.content = displayContent; + msg.thinking = thinkingContent || undefined; + }, + ); + this.syncActiveMessagesIfNeeded(targetConversationId); + this.persistConversation(targetConversationId); + } + }, + ); + + // Final cleanup of the message (if conversation still exists) + if (this.conversationExists(targetConversationId)) { + const { displayContent, thinkingContent } = + this.stripThinkingTags(streamedContent); + this.updateConversationMessage( + targetConversationId, + assistantMessage.id, + (msg) => { + msg.content = displayContent; + msg.thinking = thinkingContent || undefined; + }, + ); + this.syncActiveMessagesIfNeeded(targetConversationId); + this.persistConversation(targetConversationId); } - this.persistActiveConversation(); } catch (error) { - 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(); + this.handleStreamingError( + error, + targetConversationId, + assistantMessage.id, + "Unknown error", + ); } finally { this.isLoading = false; this.currentResponse = ""; - this.updateActiveConversation(); + this.saveConversationsToStorage(); } } @@ -1474,6 +1607,121 @@ class AppStore { }; } + /** + * Parse an SSE stream and invoke a callback for each parsed JSON chunk. + * Handles buffering, line splitting, and conversation deletion checks. + * + * @param reader - The stream reader from fetch response.body.getReader() + * @param targetConversationId - The conversation ID to check for deletion + * @param onChunk - Callback invoked with each parsed JSON object from the stream + */ + private async parseSSEStream( + reader: ReadableStreamDefaultReader, + targetConversationId: string, + onChunk: (parsed: T) => void, + ): Promise { + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (!this.conversationExists(targetConversationId)) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith("data: ")) { + const data = trimmed.slice(6); + if (data === "[DONE]") continue; + + try { + const parsed = JSON.parse(data) as T; + onChunk(parsed); + } catch { + // Skip malformed JSON + } + } + } + } + + // Process any remaining data in the buffer + if (buffer.trim() && this.conversationExists(targetConversationId)) { + const trimmed = buffer.trim(); + if (trimmed.startsWith("data: ") && trimmed.slice(6) !== "[DONE]") { + try { + const parsed = JSON.parse(trimmed.slice(6)) as T; + onChunk(parsed); + } catch { + // Skip malformed JSON + } + } + } + } + + /** + * Handle streaming errors by updating the assistant message with an error. + * + * @param error - The caught error + * @param targetConversationId - The conversation ID + * @param assistantMessageId - The assistant message ID to update + * @param errorPrefix - Optional prefix for the error message (e.g., "Failed to generate image") + */ + private handleStreamingError( + error: unknown, + targetConversationId: string, + assistantMessageId: string, + errorPrefix = "Failed to get response", + ): void { + if (this.conversationExists(targetConversationId)) { + this.updateConversationMessage( + targetConversationId, + assistantMessageId, + (msg) => { + msg.content = `Error: ${error instanceof Error ? error.message : errorPrefix}`; + }, + ); + this.syncActiveMessagesIfNeeded(targetConversationId); + this.persistConversation(targetConversationId); + } + } + + /** + * Get the model to use for a request. + * Prefers the provided modelId, then selectedChatModel, then falls back to the first running instance. + * + * @param modelId - Optional explicit model ID + * @returns The model ID to use, or null if none available + */ + private getModelForRequest(modelId?: string): string | null { + if (modelId) return modelId; + if (this.selectedChatModel) return this.selectedChatModel; + + // Try to get model from first running instance + for (const [, instanceWrapper] of Object.entries(this.instances)) { + if (instanceWrapper && typeof instanceWrapper === "object") { + const keys = Object.keys(instanceWrapper as Record); + if (keys.length === 1) { + const instance = (instanceWrapper as Record)[ + keys[0] + ] as { shardAssignments?: { modelId?: string } }; + if (instance?.shardAssignments?.modelId) { + return instance.shardAssignments.modelId; + } + } + } + } + return null; + } + /** * Send a message to the LLM and stream the response */ @@ -1494,6 +1742,10 @@ class AppStore { this.startChat(); } + // Capture the target conversation ID at the start of the request + const targetConversationId = this.activeConversationId; + if (!targetConversationId) return; + this.isLoading = true; this.currentResponse = ""; this.ttftMs = null; @@ -1537,7 +1789,7 @@ class AppStore { // Combine content with file context const fullContent = content + fileContext; - // Add user message with attachments + // Add user message directly to the target conversation const userMessage: Message = { id: generateUUID(), role: "user", @@ -1545,11 +1797,30 @@ class AppStore { timestamp: Date.now(), attachments: attachments.length > 0 ? attachments : undefined, }; - this.messages.push(userMessage); - // Create placeholder for assistant message - const assistantMessage = this.addMessage("assistant", ""); - this.updateActiveConversation(); + const targetConversation = this.conversations.find( + (c) => c.id === targetConversationId, + ); + if (!targetConversation) { + this.isLoading = false; + return; + } + targetConversation.messages.push(userMessage); + + // Create placeholder for assistant message directly in target conversation + const assistantMessage = this.addMessageToConversation( + targetConversationId, + "assistant", + "", + ); + if (!assistantMessage) { + this.isLoading = false; + return; + } + + // Sync to this.messages if viewing the target conversation + this.syncActiveMessagesIfNeeded(targetConversationId); + this.saveConversationsToStorage(); try { // Build the messages array for the API with system prompt @@ -1559,10 +1830,10 @@ class AppStore { "You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process. When files are shared with you, analyze them and respond helpfully.", }; - // Build API messages - include file content for text files + // Build API messages from the target conversation - include file content for text files const apiMessages = [ systemPrompt, - ...this.messages.slice(0, -1).map((m) => { + ...targetConversation.messages.slice(0, -1).map((m) => { // Build content including any text file attachments let msgContent = m.content; @@ -1582,28 +1853,8 @@ class AppStore { }), ]; - // Determine the model to use - prefer selectedChatModel, otherwise try to get from instances - let modelToUse = this.selectedChatModel; - if (!modelToUse) { - // Try to get model from first running instance - for (const [, instanceWrapper] of Object.entries(this.instances)) { - if (instanceWrapper && typeof instanceWrapper === "object") { - const keys = Object.keys( - instanceWrapper as Record, - ); - if (keys.length === 1) { - const instance = (instanceWrapper as Record)[ - keys[0] - ] as { shardAssignments?: { modelId?: string } }; - if (instance?.shardAssignments?.modelId) { - modelToUse = instance.shardAssignments.modelId; - break; - } - } - } - } - } - + // Determine the model to use + const modelToUse = this.getModelForRequest(); if (!modelToUse) { throw new Error( "No model selected and no running instances available. Please launch an instance first.", @@ -1641,88 +1892,59 @@ class AppStore { throw new Error("No response body"); } - const decoder = new TextDecoder(); - let fullContent = ""; - let buffer = ""; + let streamedContent = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // Process complete lines - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; // Keep incomplete line in buffer - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - if (trimmed.startsWith("data: ")) { - const data = trimmed.slice(6); - if (data === "[DONE]") continue; - - try { - const parsed = JSON.parse(data); - const tokenContent = parsed.choices?.[0]?.delta?.content; - if (tokenContent) { - // Track first token for TTFT - if (firstTokenTime === null) { - firstTokenTime = performance.now(); - this.ttftMs = firstTokenTime - requestStartTime; - } - - // Count tokens (each SSE chunk is typically one token) - tokenCount += 1; - this.totalTokens = tokenCount; - - // Update real-time TPS during streaming - if (firstTokenTime !== null && tokenCount > 1) { - const elapsed = performance.now() - firstTokenTime; - this.tps = (tokenCount / elapsed) * 1000; - } - - fullContent += tokenContent; - - // Strip thinking tags for display and extract thinking content - const { displayContent, thinkingContent } = - this.stripThinkingTags(fullContent); - this.currentResponse = displayContent; - - // Update the assistant message in place - 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 invalid JSON lines - } - } - } + interface ChatCompletionChunk { + choices?: Array<{ delta?: { content?: string } }>; } - // Process any remaining buffer - if (buffer.trim()) { - const trimmed = buffer.trim(); - if (trimmed.startsWith("data: ") && trimmed.slice(6) !== "[DONE]") { - try { - const parsed = JSON.parse(trimmed.slice(6)); - const tokenContent = parsed.choices?.[0]?.delta?.content; - if (tokenContent) { - fullContent += tokenContent; - this.persistActiveConversation(); + await this.parseSSEStream( + reader, + targetConversationId, + (parsed) => { + const tokenContent = parsed.choices?.[0]?.delta?.content; + if (tokenContent) { + // Track first token for TTFT + if (firstTokenTime === null) { + firstTokenTime = performance.now(); + this.ttftMs = firstTokenTime - requestStartTime; } - } catch { - // Skip + + // Count tokens (each SSE chunk is typically one token) + tokenCount += 1; + this.totalTokens = tokenCount; + + // Update real-time TPS during streaming + if (firstTokenTime !== null && tokenCount > 1) { + const elapsed = performance.now() - firstTokenTime; + this.tps = (tokenCount / elapsed) * 1000; + } + + streamedContent += tokenContent; + + // Strip thinking tags for display and extract thinking content + const { displayContent, thinkingContent } = + this.stripThinkingTags(streamedContent); + + // Only update currentResponse if target conversation is active + if (this.activeConversationId === targetConversationId) { + this.currentResponse = displayContent; + } + + // Update the assistant message in the target conversation + this.updateConversationMessage( + targetConversationId, + assistantMessage.id, + (msg) => { + msg.content = displayContent; + msg.thinking = thinkingContent || undefined; + }, + ); + this.syncActiveMessagesIfNeeded(targetConversationId); + this.persistConversation(targetConversationId); } - } - } + }, + ); // Calculate final TPS if (firstTokenTime !== null && tokenCount > 1) { @@ -1730,35 +1952,40 @@ class AppStore { this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second } - // 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; - // Store performance metrics on the message - if (this.ttftMs !== null) { - this.messages[idx].ttftMs = this.ttftMs; - } - if (this.tps !== null) { - this.messages[idx].tps = this.tps; - } + // Final cleanup of the message (if conversation still exists) + if (this.conversationExists(targetConversationId)) { + const { displayContent, thinkingContent } = + this.stripThinkingTags(streamedContent); + this.updateConversationMessage( + targetConversationId, + assistantMessage.id, + (msg) => { + msg.content = displayContent; + msg.thinking = thinkingContent || undefined; + // Store performance metrics on the message + if (this.ttftMs !== null) { + msg.ttftMs = this.ttftMs; + } + if (this.tps !== null) { + msg.tps = this.tps; + } + }, + ); + this.syncActiveMessagesIfNeeded(targetConversationId); + this.persistConversation(targetConversationId); } - this.persistActiveConversation(); } catch (error) { console.error("Error sending message:", error); - // Update the assistant message with error - const idx = this.messages.findIndex((m) => m.id === assistantMessage.id); - if (idx !== -1) { - this.messages[idx].content = - `Error: ${error instanceof Error ? error.message : "Failed to get response"}`; - } - this.persistActiveConversation(); + this.handleStreamingError( + error, + targetConversationId, + assistantMessage.id, + "Failed to get response", + ); } finally { this.isLoading = false; this.currentResponse = ""; - this.updateActiveConversation(); + this.saveConversationsToStorage(); } } @@ -1772,26 +1999,49 @@ class AppStore { this.startChat(); } + // Capture the target conversation ID at the start of the request + const targetConversationId = this.activeConversationId; + if (!targetConversationId) return; + this.isLoading = true; this.currentResponse = ""; - // Add user message + // Add user message directly to the target conversation const userMessage: Message = { id: generateUUID(), role: "user", content: prompt, timestamp: Date.now(), + requestType: "image-generation", }; - this.messages.push(userMessage); - // Create placeholder for assistant message with generating state - const assistantMessage = this.addMessage("assistant", ""); - this.messages[this.messages.length - 1].content = "Generating image..."; - this.updateActiveConversation(); + const targetConversation = this.conversations.find( + (c) => c.id === targetConversationId, + ); + if (!targetConversation) { + this.isLoading = false; + return; + } + targetConversation.messages.push(userMessage); + + // Create placeholder for assistant message directly in target conversation + const assistantMessage = this.addMessageToConversation( + targetConversationId, + "assistant", + "Generating image...", + ); + if (!assistantMessage) { + this.isLoading = false; + return; + } + + // Sync to this.messages if viewing the target conversation + this.syncActiveMessagesIfNeeded(targetConversationId); + this.saveConversationsToStorage(); try { // Determine the model to use - let model = modelId || this.selectedChatModel; + const model = this.getModelForRequest(modelId); if (!model) { throw new Error( "No model selected. Please select an image generation model.", @@ -1809,12 +2059,13 @@ class AppStore { const requestBody: Record = { model, prompt, + n: params.numImages, quality: params.quality, size: params.size, output_format: params.outputFormat, response_format: "b64_json", - stream: true, - partial_images: 3, + stream: params.stream, + partial_images: params.partialImages, }; if (hasAdvancedParams) { @@ -1849,78 +2100,113 @@ class AppStore { throw new Error("No response body"); } - const decoder = new TextDecoder(); - let buffer = ""; - const idx = this.messages.findIndex((m) => m.id === assistantMessage.id); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // Process complete lines - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; // Keep incomplete line in buffer - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - if (trimmed.startsWith("data: ")) { - const data = trimmed.slice(6); - if (data === "[DONE]") continue; - - try { - const parsed = JSON.parse(data); - const imageData = parsed.data?.b64_json; - - if (imageData && idx !== -1) { - const format = parsed.format || "png"; - const mimeType = `image/${format}`; - if (parsed.type === "partial") { - // Update with partial image and progress - const partialNum = (parsed.partial_index ?? 0) + 1; - const totalPartials = parsed.total_partials ?? 3; - this.messages[idx].content = - `Generating... ${partialNum}/${totalPartials}`; - this.messages[idx].attachments = [ - { - type: "generated-image", - name: `generated-image.${format}`, - preview: `data:${mimeType};base64,${imageData}`, - mimeType, - }, - ]; - } else if (parsed.type === "final") { - // Final image - this.messages[idx].content = ""; - this.messages[idx].attachments = [ - { - type: "generated-image", - name: `generated-image.${format}`, - preview: `data:${mimeType};base64,${imageData}`, - mimeType, - }, - ]; - } - } - } catch { - // Ignore parse errors for incomplete JSON - } - } - } + interface ImageGenerationChunk { + data?: { b64_json?: string }; + format?: string; + type?: "partial" | "final"; + image_index?: number; + partial_index?: number; + total_partials?: number; } + + const numImages = params.numImages; + + await this.parseSSEStream( + reader, + targetConversationId, + (parsed) => { + const imageData = parsed.data?.b64_json; + + if (imageData) { + const format = parsed.format || "png"; + const mimeType = `image/${format}`; + const imageIndex = parsed.image_index ?? 0; + + if (parsed.type === "partial") { + // Update with partial image and progress + const partialNum = (parsed.partial_index ?? 0) + 1; + const totalPartials = parsed.total_partials ?? 3; + const progressText = + numImages > 1 + ? `Generating image ${imageIndex + 1}/${numImages}... ${partialNum}/${totalPartials}` + : `Generating... ${partialNum}/${totalPartials}`; + + const partialAttachment: MessageAttachment = { + type: "generated-image", + name: `generated-image.${format}`, + preview: `data:${mimeType};base64,${imageData}`, + mimeType, + }; + + this.updateConversationMessage( + targetConversationId, + assistantMessage.id, + (msg) => { + msg.content = progressText; + if (imageIndex === 0) { + // First image - safe to replace attachments with partial preview + msg.attachments = [partialAttachment]; + } else { + // Subsequent images - keep existing finals, show partial at current position + const existingAttachments = msg.attachments || []; + // Keep only the completed final images (up to current imageIndex) + const finals = existingAttachments.slice(0, imageIndex); + msg.attachments = [...finals, partialAttachment]; + } + }, + ); + } else if (parsed.type === "final") { + // Final image - replace partial at this position + const newAttachment: MessageAttachment = { + type: "generated-image", + name: `generated-image-${imageIndex + 1}.${format}`, + preview: `data:${mimeType};base64,${imageData}`, + mimeType, + }; + + this.updateConversationMessage( + targetConversationId, + assistantMessage.id, + (msg) => { + if (imageIndex === 0) { + // First final image - replace any partial preview + msg.attachments = [newAttachment]; + } else { + // Subsequent images - keep previous finals, replace partial at current position + const existingAttachments = msg.attachments || []; + // Slice keeps indices 0 to imageIndex-1 (the previous final images) + const previousFinals = existingAttachments.slice( + 0, + imageIndex, + ); + msg.attachments = [...previousFinals, newAttachment]; + } + + // Update progress message for multiple images + if (numImages > 1 && imageIndex < numImages - 1) { + msg.content = `Generating image ${imageIndex + 2}/${numImages}...`; + } else { + msg.content = ""; + } + }, + ); + } + + this.syncActiveMessagesIfNeeded(targetConversationId); + } + }, + ); } catch (error) { console.error("Error generating image:", error); - const idx = this.messages.findIndex((m) => m.id === assistantMessage.id); - if (idx !== -1) { - this.messages[idx].content = - `Error: ${error instanceof Error ? error.message : "Failed to generate image"}`; - } + this.handleStreamingError( + error, + targetConversationId, + assistantMessage.id, + "Failed to generate image", + ); } finally { this.isLoading = false; - this.updateActiveConversation(); + this.saveConversationsToStorage(); } } @@ -1938,29 +2224,53 @@ class AppStore { this.startChat(); } + // Capture the target conversation ID at the start of the request + const targetConversationId = this.activeConversationId; + if (!targetConversationId) return; + this.isLoading = true; this.currentResponse = ""; - // Add user message with the edit prompt + // Add user message directly to the target conversation const userMessage: Message = { id: generateUUID(), role: "user", content: prompt, timestamp: Date.now(), + requestType: "image-editing", + sourceImageDataUrl: imageDataUrl, }; - this.messages.push(userMessage); - // Create placeholder for assistant message with generating state - const assistantMessage = this.addMessage("assistant", ""); - this.messages[this.messages.length - 1].content = "Editing image..."; - this.updateActiveConversation(); + const targetConversation = this.conversations.find( + (c) => c.id === targetConversationId, + ); + if (!targetConversation) { + this.isLoading = false; + return; + } + targetConversation.messages.push(userMessage); + + // Create placeholder for assistant message directly in target conversation + const assistantMessage = this.addMessageToConversation( + targetConversationId, + "assistant", + "Editing image...", + ); + if (!assistantMessage) { + this.isLoading = false; + return; + } + + // Sync to this.messages if viewing the target conversation + this.syncActiveMessagesIfNeeded(targetConversationId); + this.saveConversationsToStorage(); // Clear editing state this.editingImage = null; try { // Determine the model to use - let model = modelId || this.selectedChatModel; + const model = this.getModelForRequest(modelId); if (!model) { throw new Error( "No model selected. Please select an image generation model.", @@ -1983,8 +2293,8 @@ class AppStore { formData.append("size", params.size); formData.append("output_format", params.outputFormat); formData.append("response_format", "b64_json"); - formData.append("stream", "1"); // Use "1" instead of "true" for reliable FastAPI boolean parsing - formData.append("partial_images", "3"); + formData.append("stream", params.stream ? "1" : "0"); + formData.append("partial_images", params.partialImages.toString()); formData.append("input_fidelity", params.inputFidelity); // Advanced params @@ -2038,78 +2348,75 @@ class AppStore { throw new Error("No response body"); } - const decoder = new TextDecoder(); - let buffer = ""; - const idx = this.messages.findIndex((m) => m.id === assistantMessage.id); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // Process complete lines - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; // Keep incomplete line in buffer - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - if (trimmed.startsWith("data: ")) { - const data = trimmed.slice(6); - if (data === "[DONE]") continue; - - try { - const parsed = JSON.parse(data); - const imageData = parsed.data?.b64_json; - - if (imageData && idx !== -1) { - const format = parsed.format || "png"; - const mimeType = `image/${format}`; - if (parsed.type === "partial") { - // Update with partial image and progress - const partialNum = (parsed.partial_index ?? 0) + 1; - const totalPartials = parsed.total_partials ?? 3; - this.messages[idx].content = - `Editing... ${partialNum}/${totalPartials}`; - this.messages[idx].attachments = [ - { - type: "generated-image", - name: `edited-image.${format}`, - preview: `data:${mimeType};base64,${imageData}`, - mimeType, - }, - ]; - } else if (parsed.type === "final") { - // Final image - this.messages[idx].content = ""; - this.messages[idx].attachments = [ - { - type: "generated-image", - name: `edited-image.${format}`, - preview: `data:${mimeType};base64,${imageData}`, - mimeType, - }, - ]; - } - } - } catch { - // Ignore parse errors for incomplete JSON - } - } - } + interface ImageEditChunk { + data?: { b64_json?: string }; + format?: string; + type?: "partial" | "final"; + partial_index?: number; + total_partials?: number; } + + await this.parseSSEStream( + reader, + targetConversationId, + (parsed) => { + const imageData = parsed.data?.b64_json; + + if (imageData) { + const format = parsed.format || "png"; + const mimeType = `image/${format}`; + if (parsed.type === "partial") { + // Update with partial image and progress + const partialNum = (parsed.partial_index ?? 0) + 1; + const totalPartials = parsed.total_partials ?? 3; + this.updateConversationMessage( + targetConversationId, + assistantMessage.id, + (msg) => { + msg.content = `Editing... ${partialNum}/${totalPartials}`; + msg.attachments = [ + { + type: "generated-image", + name: `edited-image.${format}`, + preview: `data:${mimeType};base64,${imageData}`, + mimeType, + }, + ]; + }, + ); + } else if (parsed.type === "final") { + // Final image + this.updateConversationMessage( + targetConversationId, + assistantMessage.id, + (msg) => { + msg.content = ""; + msg.attachments = [ + { + type: "generated-image", + name: `edited-image.${format}`, + preview: `data:${mimeType};base64,${imageData}`, + mimeType, + }, + ]; + }, + ); + } + this.syncActiveMessagesIfNeeded(targetConversationId); + } + }, + ); } catch (error) { console.error("Error editing image:", error); - const idx = this.messages.findIndex((m) => m.id === assistantMessage.id); - if (idx !== -1) { - this.messages[idx].content = - `Error: ${error instanceof Error ? error.message : "Failed to edit image"}`; - } + this.handleStreamingError( + error, + targetConversationId, + assistantMessage.id, + "Failed to edit image", + ); } finally { this.isLoading = false; - this.updateActiveConversation(); + this.saveConversationsToStorage(); } } @@ -2136,6 +2443,54 @@ class AppStore { this.conversations.find((c) => c.id === this.activeConversationId) || null ); } + + /** + * Start a download on a specific node + */ + async startDownload(nodeId: string, shardMetadata: object): Promise { + try { + const response = await fetch("/download/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + targetNodeId: nodeId, + shardMetadata: shardMetadata, + }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to start download: ${response.status} - ${errorText}`, + ); + } + } catch (error) { + console.error("Error starting download:", error); + throw error; + } + } + + /** + * Delete a downloaded model from a specific node + */ + async deleteDownload(nodeId: string, modelId: string): Promise { + try { + const response = await fetch( + `/download/${encodeURIComponent(nodeId)}/${encodeURIComponent(modelId)}`, + { + method: "DELETE", + }, + ); + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to delete download: ${response.status} - ${errorText}`, + ); + } + } catch (error) { + console.error("Error deleting download:", error); + throw error; + } + } } export const appStore = new AppStore(); @@ -2241,3 +2596,9 @@ export const setImageGenerationParams = ( ) => appStore.setImageGenerationParams(params); export const resetImageGenerationParams = () => appStore.resetImageGenerationParams(); + +// Download actions +export const startDownload = (nodeId: string, shardMetadata: object) => + appStore.startDownload(nodeId, shardMetadata); +export const deleteDownload = (nodeId: string, modelId: string) => + appStore.deleteDownload(nodeId, modelId); diff --git a/dashboard/src/routes/downloads/+page.svelte b/dashboard/src/routes/downloads/+page.svelte index a7ee2003..72fe149c 100644 --- a/dashboard/src/routes/downloads/+page.svelte +++ b/dashboard/src/routes/downloads/+page.svelte @@ -6,6 +6,8 @@ type DownloadProgress, refreshState, lastUpdate as lastUpdateStore, + startDownload, + deleteDownload, } from "$lib/stores/app.svelte"; import HeaderNav from "$lib/components/HeaderNav.svelte"; @@ -28,6 +30,7 @@ etaMs: number; status: "completed" | "downloading"; files: FileProgress[]; + shardMetadata?: Record; }; type NodeEntry = { @@ -269,6 +272,12 @@ } } + // Extract shard_metadata for use with download actions + const shardMetadata = (downloadPayload.shard_metadata ?? + downloadPayload.shardMetadata) as + | Record + | undefined; + const entry: ModelEntry = { modelId, prettyName, @@ -285,6 +294,7 @@ ? "completed" : "downloading", files, + shardMetadata, }; const existing = modelMap.get(modelId); @@ -469,6 +479,52 @@ > {pct.toFixed(1)}% + {#if model.status !== "completed" && model.shardMetadata} + + {/if} + {#if model.status === "completed"} + + {/if}