From 1e5858c7b7472b918152cfd9ad50a0f1cccb35e1 Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Fri, 21 Nov 2025 12:41:13 -0600 Subject: [PATCH] Fix StatusBox auto-scroll and resizing logic --- src/MainComponent.h | 22 ++++ src/WebModel.h | 9 ++ src/client/Client.h | 10 ++ src/client/GradioClient.cpp | 212 ++++++++++++++++++++++++++++++++---- src/errors.h | 26 ++++- src/gui/StatusComponent.cpp | 71 ++++++++++-- src/gui/StatusComponent.h | 12 +- 7 files changed, 323 insertions(+), 39 deletions(-) diff --git a/src/MainComponent.h b/src/MainComponent.h index 0a7cb0ce..ebcd93b4 100644 --- a/src/MainComponent.h +++ b/src/MainComponent.h @@ -1042,6 +1042,28 @@ class MainComponent : public Component, return; } + // Hook remote messages from Gradio/Stability clients into StatusBox + model->getClient().onRemoteMessage = + [this](RemoteMessageType type, const juce::String& msg) + { + juce::MessageManager::callAsync([this, type, msg] + { + // Forward messages into the status box UI + switch (type) + { + case RemoteMessageType::Info: + statusBox->appendRemoteMessage("info", msg); + break; + case RemoteMessageType::Warning: + statusBox->appendRemoteMessage("warning", msg); + break; + case RemoteMessageType::Error: + statusBox->appendRemoteMessage("error", msg); + break; + } + }); + }; + // Set setWantsKeyboardFocus to true for this component // Doing that, everytime we click outside the modelPathTextBox, // the focus will be taken away from the modelPathTextBox diff --git a/src/WebModel.h b/src/WebModel.h index 4f901c33..9503c590 100644 --- a/src/WebModel.h +++ b/src/WebModel.h @@ -618,6 +618,15 @@ class WebModel : public Model } ModelStatus getStatus() { return status2; } + + // forward log callback into the active client + void setLogCallback(Client::LogCallback callback) + { + if (loadedClient) + loadedClient->setLogCallback(callback); + if (tempClient) + tempClient->setLogCallback(callback); + } void setStatus(ModelStatus status) { status2 = status; } diff --git a/src/client/Client.h b/src/client/Client.h index fb55e2d3..f7279c39 100644 --- a/src/client/Client.h +++ b/src/client/Client.h @@ -15,6 +15,13 @@ using namespace juce; +enum class RemoteMessageType +{ + Info, + Warning, + Error +}; + class Client { public: @@ -40,6 +47,9 @@ class Client //OpResult queryToken(const String& token) const; virtual OpResult validateToken(const String& newToken) const; + std::function onRemoteMessage = + [](RemoteMessageType, const juce::String&) {}; + protected: String getAuthorizationHeader(String t = "") const; String getAcceptHeader() const; diff --git a/src/client/GradioClient.cpp b/src/client/GradioClient.cpp index 8eaa0983..6cbb1c44 100644 --- a/src/client/GradioClient.cpp +++ b/src/client/GradioClient.cpp @@ -464,8 +464,6 @@ OpResult GradioClient::getResponseFromEventID(const String callID, error.type = ErrorType::HttpRequestError; // Now we make a GET request to the endpoint with the event ID appended - // The endpoint for the get request is the same as the post request with - // /{eventID} appended URL gradioEndpoint = spaceInfo.gradio; URL getEndpoint = gradioEndpoint.getChildURL("gradio_api") .getChildURL("call") @@ -483,7 +481,7 @@ OpResult GradioClient::getResponseFromEventID(const String callID, .withResponseHeaders(&responseHeaders) .withStatusCode(&statusCode) .withNumRedirectsToFollow(5); - // .withHttpRequestCmd ("POST"); + std::unique_ptr stream(getEndpoint.createInputStream(options)); DBG("Input stream created"); @@ -498,40 +496,210 @@ OpResult GradioClient::getResponseFromEventID(const String callID, return OpResult::fail(error); } - // Stream the response + // We’ll collect any info/warning/error logs we see along the way so that, + // if something fails, we can append them to the error message. + StringArray infoLogs; + StringArray warningLogs; + + // Stream the SSE response while (! stream->isExhausted()) { - response = stream->readNextLine(); + String line = stream->readNextLine(); + + if (line.isEmpty()) + continue; + + DBG("SSE line: " + line); - DBG(eventID); - DBG(response); - DBG(response.length()); + if (line.contains("event: " + enumToString(GradioEvents::complete))) + { + // Next line should be "data: " + String dataLine = stream->readNextLine(); + DBG("Complete data line: " + dataLine); + response = dataLine; + return OpResult::ok(); + } - if (response.contains(enumToString(GradioEvents::complete))) + // INFO / WARNING messages (gr.Info / gr.Warning) + // These do NOT stop processing. We just record them. + if (line.startsWith("event: info") || line.contains("event: info")) { - response = stream->readNextLine(); - break; + String dataLine = stream->readNextLine(); + DBG("Info data line: " + dataLine); + + if (dataLine.startsWith("data:")) + { + auto jsonData = dataLine.fromFirstOccurrenceOf("data:", false, true).trim(); + var parsed = JSON::parse(jsonData); + + if (parsed.isObject()) + { + if (auto* obj = parsed.getDynamicObject()) + { + juce::String msg; + if (obj->hasProperty("log")) + msg = obj->getProperty("log").toString(); + else if (obj->hasProperty("message")) + msg = obj->getProperty("message").toString(); + + if (msg.isNotEmpty()) + { + infoLogs.add(msg); + DBG("Captured info log: " + msg); + onRemoteMessage(RemoteMessageType::Info, msg); + } + } + } + } + + continue; } - else if (response.contains(enumToString(GradioEvents::error))) + + if (line.startsWith("event: warning") || line.contains("event: warning")) { - response = stream->readNextLine(); + String dataLine = stream->readNextLine(); + DBG("Warning data line: " + dataLine); - if ((statusCode == 200) & (response.contains("data: null"))) + if (dataLine.startsWith("data:")) { - error.devMessage = - "Your ZeroGPU quota has been reached.\nHave you added a Hugging Face access token in settings yet?"; - return OpResult::fail(error); + auto jsonData = dataLine.fromFirstOccurrenceOf("data:", false, true).trim(); + var parsed = JSON::parse(jsonData); + + if (parsed.isObject()) + { + if (auto* obj = parsed.getDynamicObject()) + { + juce::String msg; + if (obj->hasProperty("log")) + msg = obj->getProperty("log").toString(); + else if (obj->hasProperty("message")) + msg = obj->getProperty("message").toString(); + + if (msg.isNotEmpty()) + { + warningLogs.add(msg); + DBG("Captured warning log: " + msg); + onRemoteMessage(RemoteMessageType::Warning, msg); + } + } + } + } + + continue; + } + + // ERROR: either ZeroGPU / worker error / Python exception + if (line.contains("event: " + enumToString(GradioEvents::error)) + || line.startsWith("event: error")) + { + String dataLine = stream->readNextLine(); + DBG("Error data line: " + dataLine); + + // parse a real error payload after "data:" + juce::String errorPayload; + if (dataLine.startsWith("data:")) + errorPayload = + dataLine.fromFirstOccurrenceOf("data:", false, true).trim(); + + // Some HF / ZeroGPU paths send "data: null" + bool isNullData = errorPayload.isEmpty() + || errorPayload == "null" + || errorPayload == "None"; + + // Read a few more lines for additional context (stack traces, etc.) + juce::String additionalInfo; + const int maxContextLines = 10; + for (int i = 0; i < maxContextLines && ! stream->isExhausted(); ++i) + { + auto nextLine = stream->readNextLine(); + if (nextLine.isEmpty()) + continue; + if (nextLine.startsWith("event:")) + break; // next SSE event, stop + additionalInfo += nextLine + "\n"; + } + additionalInfo = additionalInfo.trim(); + + // Default to remote application error, not HTTP error + error.type = ErrorType::RemoteAppError; + + // interpret errorPayload as JSON + if (! isNullData) + { + var parsed = JSON::parse(errorPayload); + if (parsed.isObject()) + { + if (auto* obj = parsed.getDynamicObject()) + { + juce::String msg; + if (obj->hasProperty("error")) + msg = obj->getProperty("error").toString(); + else if (obj->hasProperty("message")) + msg = obj->getProperty("message").toString(); + else if (obj->hasProperty("detail")) + msg = obj->getProperty("detail").toString(); + + if (msg.isNotEmpty()) + { + error.devMessage = msg; + onRemoteMessage(RemoteMessageType::Error, msg); + } + } + } + + // If JSON parse failed or we didn't get a message, fall back to raw + if (error.devMessage.isEmpty()) + error.devMessage = errorPayload; } else { - error.code = statusCode; - error.devMessage = response; - return OpResult::fail(error); + // data:null case – we no longer assume "ZeroGPU quota". + // Treat it as a generic remote worker error, but keep any context we have. + if (additionalInfo.isNotEmpty()) + error.devMessage = additionalInfo; + else + error.devMessage = + "The remote worker reported an error with no additional details.\n" + "Open the model's Hugging Face Space to inspect the exact cause."; } + + // Append any info/warning logs for more context + if (! infoLogs.isEmpty() || ! warningLogs.isEmpty()) + { + juce::String logContext = "\n\n Logs from remote model \n"; + for (auto& msg : infoLogs) + logContext += "[info] " + msg + "\n"; + for (auto& msg : warningLogs) + logContext += "[warning] " + msg + "\n"; + error.devMessage += logContext; + } + + error.code = statusCode; + return OpResult::fail(error); + } + + // HEARTBEAT: keep-alive, ignore + if (line.contains("event: " + enumToString(GradioEvents::heartbeat)) + || line.startsWith("event: heartbeat")) + { + continue; + } + + // GENERATING / progress – currently we only debug-print + if (line.contains("event: " + enumToString(GradioEvents::generating)) + || line.startsWith("event: generating")) + { + String dataLine = stream->readNextLine(); + DBG("Generating / progress data: " + dataLine); + continue; } } - return OpResult::ok(); -} + + // If we exit the loop without a complete or error event, treat as an HTTP-ish error + error.code = statusCode; + error.devMessage = "Did not receive a complete or error event from the remote worker."; + return OpResult::fail(error); +} OpResult GradioClient::getControls(Array& inputComponents, Array& outputComponents, diff --git a/src/errors.h b/src/errors.h index 0808d852..c4c9935d 100644 --- a/src/errors.h +++ b/src/errors.h @@ -22,6 +22,7 @@ enum class ErrorType UnknownError, UnsupportedControlType, UnknownLabelType, + RemoteAppError, }; struct Error @@ -40,12 +41,35 @@ struct Error */ static void fillUserMessage(Error& error) { - error.userMessage = error.devMessage; + if (error.userMessage.isEmpty()){ + error.userMessage = error.devMessage; + } if (error.devMessage.contains("503")) { error.userMessage = "The gradio app is currently paused by the developer. Please try again later."; } + else if (error.type == ErrorType::RemoteAppError) + { + // Make the message a bit more friendly depending on common Python errors + if (error.devMessage.containsIgnoreCase("NameError")) + { + error.userMessage = + "The remote model crashed with a NameError (a variable or function wasn't defined).\n\n" + + error.devMessage; + } + else if (error.devMessage.containsIgnoreCase("TypeError")) + { + error.userMessage = + "The remote model crashed with a TypeError (unexpected type passed in the model code).\n\n" + + error.devMessage; + } + else + { + error.userMessage = "A remote processing error occurred in the model.\n\n" + + error.devMessage; + } + } else if (error.type == ErrorType::HttpRequestError) { // if (error.devMessage.contains("POST request to controls")) diff --git a/src/gui/StatusComponent.cpp b/src/gui/StatusComponent.cpp index 9cb7f00d..0c557882 100644 --- a/src/gui/StatusComponent.cpp +++ b/src/gui/StatusComponent.cpp @@ -4,12 +4,10 @@ InstructionBox::InstructionBox(float fontSize, juce::Justification justification { statusLabel.setJustificationType(justification); statusLabel.setFont(fontSize); - // statusLabel.setColour(juce::Label::textColourId, juce::Colours::black); statusLabel.setColour(juce::Label::textColourId, juce::Colour(0xE0, 0xE0, 0xE0)); addAndMakeVisible(statusLabel); } -// void InstructionBox::paint(juce::Graphics& g) { g.fillAll(juce::Colours::lightgrey); } void InstructionBox::paint(juce::Graphics& g) { // Option 1: Dark theme @@ -29,27 +27,76 @@ void InstructionBox::clearStatusMessage() { statusLabel.setText({}, juce::dontSe StatusBox::StatusBox(float fontSize, juce::Justification justification) { - statusLabel.setJustificationType(justification); - statusLabel.setFont(fontSize); - // statusLabel.setColour(juce::Label::textColourId, juce::Colours::black); - statusLabel.setColour(juce::Label::textColourId, juce::Colour(0xE0, 0xE0, 0xE0)); - addAndMakeVisible(statusLabel); + addAndMakeVisible(viewport); + + contentLabel.setJustificationType(juce::Justification::topLeft); + contentLabel.setFont(fontSize); + contentLabel.setColour(juce::Label::textColourId, juce::Colour(0xE0, 0xE0, 0xE0)); + contentLabel.setInterceptsMouseClicks(false, false); + contentLabel.setMinimumHorizontalScale(1.0f); + + viewport.setViewedComponent(&contentLabel, false); + viewport.setScrollBarsShown(true, false); // vertical yes, horizontal no } -// void StatusBox::paint(juce::Graphics& g) { g.fillAll(juce::Colours::lightgrey); } void StatusBox::paint(juce::Graphics& g) { - // Option 1: Dark theme g.setColour(juce::Colour(0x33, 0x33, 0x33)); g.fillAll(); g.setColour(juce::Colour(0x44, 0x44, 0x44)); g.drawRect(getLocalBounds(), 1); } -void StatusBox::resized() { statusLabel.setBounds(getLocalBounds()); } + +void StatusBox::resized() +{ + viewport.setBounds(getLocalBounds()); + + contentLabel.setSize( + viewport.getWidth() - viewport.getScrollBarThickness(), + juce::jmax(contentLabel.getHeight(), viewport.getHeight()) + ); +} + +void StatusBox::appendRemoteMessage(const juce::String& level, + const juce::String& message) +{ + juce::String prefix; + + if (level == "info") prefix = "[info] "; + else if (level == "warning") prefix = "[warning] "; + else if (level == "error") prefix = "[error] "; + + accumulatedMessages << prefix << message << "\n"; + + contentLabel.setText(accumulatedMessages, juce::dontSendNotification); + + // Resize height to fit content + contentLabel.setSize( + viewport.getWidth() - viewport.getScrollBarThickness(), + contentLabel.getTextHeight() + ); + + // Auto scroll to bottom + viewport.setViewPosition( + 0, + juce::jmax(0, contentLabel.getBottom() - viewport.getMaximumVisibleHeight()) + ); + repaint(); +} void StatusBox::setStatusMessage(const juce::String& message) { - statusLabel.setText(message, juce::dontSendNotification); + accumulatedMessages = message; + contentLabel.setText(message, juce::dontSendNotification); + + contentLabel.setSize( + viewport.getWidth() - viewport.getScrollBarThickness(), + contentLabel.getTextHeight() + ); } -void StatusBox::clearStatusMessage() { statusLabel.setText({}, juce::dontSendNotification); } +void StatusBox::clearStatusMessage() +{ + accumulatedMessages.clear(); + contentLabel.setText({}, juce::dontSendNotification); +} diff --git a/src/gui/StatusComponent.h b/src/gui/StatusComponent.h index c7911341..fa27cc09 100644 --- a/src/gui/StatusComponent.h +++ b/src/gui/StatusComponent.h @@ -32,14 +32,18 @@ class StatusBox : public juce::Component public: StatusBox(float fontSize = 15.0f, juce::Justification justification = juce::Justification::centred); + void paint(juce::Graphics& g) override; void resized() override; + void setStatusMessage(const juce::String& message); void clearStatusMessage(); - -protected: - juce::Label statusLabel; + void appendRemoteMessage(const juce::String& level, const juce::String& message); private: + juce::Viewport viewport; // scroll container + juce::Label contentLabel; // text buffer + juce::String accumulatedMessages; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(StatusBox) -}; +}; \ No newline at end of file