From 98a9d63a27872902159c522b5c01b7d4a42fbcdd Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Mon, 2 Mar 2026 11:07:53 -0600 Subject: [PATCH 01/18] Gradio error handling and status updates --- src/clients/Client.h | 3 + src/clients/GradioClient.h | 279 +++++++++++++++++++++++++++++++-- src/utils/Errors.h | 20 ++- src/utils/Messages.h | 135 ++++++++++++++++ src/widgets/StatusAreaWidget.h | 114 ++++++++++---- 5 files changed, 501 insertions(+), 50 deletions(-) create mode 100644 src/utils/Messages.h diff --git a/src/clients/Client.h b/src/clients/Client.h index 5596eb01..588b74a8 100644 --- a/src/clients/Client.h +++ b/src/clients/Client.h @@ -14,6 +14,7 @@ #include "../utils/Errors.h" #include "../utils/Labels.h" #include "../utils/Logging.h" +#include "../utils/Messages.h" #include "../utils/Settings.h" using namespace juce; @@ -292,6 +293,8 @@ class Client String getCommonHeaders() const { return getAuthorizationHeader() + acceptHeader; } String getJSONHeaders() const { return getCommonHeaders() + contentTypeJSONHeader; } + SharedResourcePointer statusMessage; + private: String getAuthorizationHeader() const { diff --git a/src/clients/GradioClient.h b/src/clients/GradioClient.h index 9b77390f..5f144ed8 100644 --- a/src/clients/GradioClient.h +++ b/src/clients/GradioClient.h @@ -18,7 +18,6 @@ class GradioClient : public Client Complete, Heartbeat, Error - //Generating }; GradioClient() @@ -643,7 +642,7 @@ class GradioClient : public Client return OpResult::ok(); } - String extractPayLoad(String response) + String extractPayload(String response) { String payload = response.trim(); @@ -655,6 +654,225 @@ class GradioClient : public Client return payload; } + static bool isEventLine(const String& line, GradioEvents event) + { + return line.trim().equalsIgnoreCase("event: " + enumToString(event)); + } + + static String extractErrorTextFromPayload(const String& payload) + { + String normalizedPayload = payload.trim(); + + if (normalizedPayload.isEmpty() || normalizedPayload.equalsIgnoreCase("null") + || normalizedPayload.equalsIgnoreCase("none")) + { + return ""; + } + + var parsedPayload = JSON::parse(normalizedPayload); + + if (auto* payloadDict = parsedPayload.getDynamicObject()) + { + for (auto key : { "error", "message", "detail" }) + { + if (payloadDict->hasProperty(key)) + { + String value = payloadDict->getProperty(key).toString().trim(); + + if (value.isNotEmpty() && ! value.equalsIgnoreCase("null")) + { + return value; + } + } + } + } + + return normalizedPayload; + } + + static bool isExplicitQuotaError(const String& text) + { + if (text.isEmpty()) + { + return false; + } + + if (text.containsIgnoreCase("exceeded your gpu quota")) + { + return true; + } + + if (text.containsIgnoreCase("gradio_client.exceptions.apperror") + && text.containsIgnoreCase("usage quota")) + { + return true; + } + + if (text.containsIgnoreCase("spaces.zero.gradio.htmlerror") + && text.containsIgnoreCase("gpu quota")) + { + return true; + } + + return false; + } + + static String extractExceptionLabel(const String& text) + { + if (text.isEmpty()) + { + return ""; + } + + for (auto label : { "NameError", "TypeError", "ImportError", "ModuleNotFoundError", + "ValueError", "KeyError", "AttributeError", "RuntimeError", + "AssertionError", "IndexError", "SyntaxError", "OSError" }) + { + if (text.containsIgnoreCase(label)) + { + return label; + } + } + + StringArray tokens = StringArray::fromTokens( + text, " \t\r\n,:;()[]{}<>\"'`\\/|", ""); + + for (auto& token : tokens) + { + String trimmedToken = token.trim(); + + if (trimmedToken.length() > 5 && trimmedToken.endsWithIgnoreCase("Error") + && ! trimmedToken.equalsIgnoreCase("error")) + { + return trimmedToken; + } + } + + return ""; + } + + static String extractShortReason(const String& text) + { + String firstLine = text.upToFirstOccurrenceOf("\n", false, false).trim(); + + if (firstLine.length() > 120) + { + firstLine = firstLine.substring(0, 120).trimEnd() + "..."; + } + + return firstLine; + } + + String collectErrorContextText(std::unique_ptr& stream, int maxLines = 8) + { + String contextText; + + for (int i = 0; i < maxLines && ! stream->isExhausted(); ++i) + { + String contextLine = stream->readNextLine().trim(); + + if (contextLine.isEmpty()) + { + continue; + } + + if (contextLine.startsWithIgnoreCase("event:")) + { + break; + } + + String normalizedContext = extractErrorTextFromPayload(extractPayload(contextLine)); + + if (normalizedContext.isEmpty()) + { + continue; + } + + if (contextText.isNotEmpty()) + { + contextText += "\n"; + } + + contextText += normalizedContext; + } + + return contextText; + } + + static String extractMessageTypeFromPayload(const String& payload) + { + String normalizedPayload = payload.trim(); + + if (normalizedPayload.isEmpty() || normalizedPayload.equalsIgnoreCase("null") + || normalizedPayload.equalsIgnoreCase("none")) + { + return ""; + } + + var parsedPayload = JSON::parse(normalizedPayload); + + if (auto* payloadDict = parsedPayload.getDynamicObject()) + { + if (payloadDict->hasProperty("msg")) + { + String value = payloadDict->getProperty("msg").toString().trim(); + + if (value.isNotEmpty() && ! value.equalsIgnoreCase("null")) + { + return value; + } + } + } + + return ""; + } + + static String formatProcessMessage(const String& messageType) + { + if (messageType.isEmpty()) + { + return ""; + } + + if (messageType.equalsIgnoreCase("process_starts")) + { + return "[process] started"; + } + + if (messageType.equalsIgnoreCase("process_completed")) + { + return "[process] completed"; + } + + if (messageType.startsWithIgnoreCase("process_")) + { + String detail = + messageType.fromFirstOccurrenceOf("process_", false, false).replace("_", " "); + return "[process] " + detail; + } + + return "[status] " + messageType.replace("_", " "); + } + + void appendProcessMessageFromDataLine(const String& dataLine) + { + if (statusMessage == nullptr) + { + return; + } + + String payload = extractPayload(dataLine); + String messageType = extractMessageTypeFromPayload(payload); + String statusText = formatProcessMessage(messageType); + + if (statusText.isEmpty()) + { + return; + } + + statusMessage->setMessage(statusText); + } + // String response version OpResult makeGETRequest(const URL endpoint, String& response, @@ -676,32 +894,69 @@ class GradioClient : public Client DBG_AND_LOG("GradioClient::makeGETRequest: Streamed response \"" << response << "\"."); - if (response.containsIgnoreCase(enumToString(GradioEvents::Complete))) + String eventLine = response.trim(); + + if (eventLine.isEmpty()) { - response = extractPayLoad(stream->readNextLine()); + continue; + } + + if (isEventLine(eventLine, GradioEvents::Complete)) + { + response = extractPayload(stream->readNextLine()); DBG_AND_LOG("GradioClient::makeGETRequest: Final response \"" << response << "\"."); break; } - else if (response.containsIgnoreCase(enumToString(GradioEvents::Error))) + else if (isEventLine(eventLine, GradioEvents::Error)) { - response = stream->readNextLine(); + String errorDataLine = stream->readNextLine(); + + DBG_AND_LOG( + "GradioClient::makeGETRequest: Error response \"" << errorDataLine << "\"."); + + String payload = extractPayload(errorDataLine); + String diagnosticText = extractErrorTextFromPayload(payload); + String contextText = collectErrorContextText(stream); + + String classificationText = diagnosticText; + + if (contextText.isNotEmpty()) + { + if (classificationText.isNotEmpty()) + { + classificationText += "\n"; + } + + classificationText += contextText; + } - DBG_AND_LOG("GradioClient::makeGETRequest: Error response \"" << response << "\"."); + String reasonText = diagnosticText.isNotEmpty() ? diagnosticText : contextText; - // TODO - could potentially identify other errors (e.g., too many requests) + String reason = extractExceptionLabel(reasonText); - return OpResult::fail(GradioError { GradioError::Type::RuntimeError, errorPath }); + if (reason.isEmpty() && reasonText.isNotEmpty()) + { + reason = extractShortReason(reasonText); + } + + GradioError::Type errorType = + isExplicitQuotaError(classificationText) ? GradioError::Type::QuotaExceeded + : GradioError::Type::RuntimeError; + + return OpResult::fail(GradioError { errorType, errorPath, reason }); + } + else if (eventLine.startsWithIgnoreCase("data:")) + { + appendProcessMessageFromDataLine(eventLine); } else { // TODO - what other information is available? - // Informational or progress events + // Unhandled SSE events (e.g., heartbeat) // Examples: // - event: heartbeat - // - event: log - // - event: progress } } diff --git a/src/utils/Errors.h b/src/utils/Errors.h index fd9c8318..6a905457 100644 --- a/src/utils/Errors.h +++ b/src/utils/Errors.h @@ -221,12 +221,14 @@ struct GradioError { enum class Type { - RuntimeError + RuntimeError, + QuotaExceeded }; Type type; String endpointPath; + String reason; }; inline String toUserMessage(const GradioError& e) @@ -237,15 +239,21 @@ inline String toUserMessage(const GradioError& e) { case GradioError::Type::RuntimeError: - userMessage = "A runtime error occurred at endpoint"; + userMessage = "The Hugging Face Space reported a runtime error"; - if (e.endpointPath.isNotEmpty()) + if (e.reason.isNotEmpty()) { - userMessage += " \"" + e.endpointPath + "\""; + userMessage += " (" + e.reason + ")"; } - userMessage += ". If this is a Hugging Face space running on ZeroGPU, this " - "can also indicate a user has exceeded their daily ZeroGPU quota."; + userMessage += ". Please open the Space logs for details."; + + return userMessage; + + case GradioError::Type::QuotaExceeded: + + userMessage = "ZeroGPU quota appears to be exceeded for this Space. " + "Please try again later or use an account with available quota."; return userMessage; } diff --git a/src/utils/Messages.h b/src/utils/Messages.h new file mode 100644 index 00000000..6c980748 --- /dev/null +++ b/src/utils/Messages.h @@ -0,0 +1,135 @@ +/** + * @file Messages.h + * @brief Shared message resources used across UI and clients. + * @author saumya-pailwan + */ + +#pragma once + +#include + +using namespace juce; + +struct SharedMessage : public ChangeBroadcaster +{ + virtual ~SharedMessage() = default; + + virtual void setMessage(const String& m) + { + { + const ScopedLock lock(messageLock); + message = m; + } + + sendChangeMessage(); + } + + virtual void clearMessage() + { + { + const ScopedLock lock(messageLock); + message.clear(); + } + + sendChangeMessage(); + } + + String getMessage() const + { + const ScopedLock lock(messageLock); + return message; + } + +protected: + mutable CriticalSection messageLock; + String message; +}; + +struct StatusHistorySnapshot +{ + uint64 revision = 0; + String lastEntry; + uint64 trimRevision = 0; + uint64 clearRevision = 0; +}; + +struct StatusMessage : SharedMessage +{ + static constexpr int maxHistoryEntries = 250; + + void setMessage(const String& m) override + { + { + const ScopedLock lock(messageLock); + message = m; + appendHistoryEntryUnsafe(m); + } + + sendChangeMessage(); + } + + void clearMessage() override + { + { + const ScopedLock lock(messageLock); + message.clear(); + history.clear(); + ++revision; + ++clearRevision; + lastEntry.clear(); + } + + sendChangeMessage(); + } + + String getHistoryText() const + { + const ScopedLock lock(messageLock); + return history.joinIntoString("\n"); + } + + StatusHistorySnapshot getHistorySnapshot() + { + const ScopedLock lock(messageLock); + return StatusHistorySnapshot { revision, lastEntry, trimRevision, clearRevision }; + } + +private: + void appendHistoryEntryUnsafe(const String& entryText) + { + if (entryText.isEmpty()) + { + return; + } + + String timestamp = Time::getCurrentTime().formatted("%H:%M:%S"); + + lastEntry = "[" + timestamp + "] " + entryText; + history.add(lastEntry); + + bool didTrim = false; + + while (history.size() > maxHistoryEntries) + { + history.remove(0); + didTrim = true; + } + + ++revision; + + if (didTrim) + { + ++trimRevision; + } + } + + uint64 revision = 0; + uint64 trimRevision = 0; + uint64 clearRevision = 0; + String lastEntry; + StringArray history; +}; + +struct InstructionsMessage : SharedMessage +{ +}; diff --git a/src/widgets/StatusAreaWidget.h b/src/widgets/StatusAreaWidget.h index 3c7189a3..3693e815 100644 --- a/src/widgets/StatusAreaWidget.h +++ b/src/widgets/StatusAreaWidget.h @@ -8,31 +8,9 @@ #include -using namespace juce; - -struct SharedMessage : public ChangeBroadcaster -{ - void setMessage(const String& m) - { - message = m; - sendChangeMessage(); - } - - void clearMessage() - { - message.clear(); - sendChangeMessage(); - } - - String message; -}; +#include "../utils/Messages.h" -struct StatusMessage : SharedMessage -{ -}; -struct InstructionsMessage : SharedMessage -{ -}; +using namespace juce; template class MessageBox : public Component, ChangeListener @@ -51,7 +29,7 @@ class MessageBox : public Component, ChangeListener ~MessageBox() override { sharedMessage->removeChangeListener(this); } - void paint(Graphics& g) + void paint(Graphics& g) override { g.setColour(Colour(0x33, 0x33, 0x33)); g.fillAll(); @@ -60,11 +38,11 @@ class MessageBox : public Component, ChangeListener g.drawRect(getLocalBounds(), 1); } - void resized() { messageLabel.setBounds(getLocalBounds()); } + void resized() override { messageLabel.setBounds(getLocalBounds()); } - void changeListenerCallback(ChangeBroadcaster* /*source*/) + void changeListenerCallback(ChangeBroadcaster* /*source*/) override { - messageLabel.setText(sharedMessage->message, dontSendNotification); + messageLabel.setText(sharedMessage->getMessage(), dontSendNotification); } private: @@ -75,16 +53,88 @@ class MessageBox : public Component, ChangeListener using StatusBox = MessageBox; using InstructionsBox = MessageBox; +class StatusHistoryBox : public Component, ChangeListener +{ +public: + StatusHistoryBox() + { + historyEditor.setMultiLine(true); + historyEditor.setReadOnly(true); + historyEditor.setScrollbarsShown(false); + historyEditor.setCaretVisible(false); + historyEditor.setPopupMenuEnabled(false); + historyEditor.setColour(TextEditor::backgroundColourId, Colour(0x33, 0x33, 0x33)); + historyEditor.setColour(TextEditor::textColourId, Colour(0xE0, 0xE0, 0xE0)); + + addAndMakeVisible(historyEditor); + + StatusHistorySnapshot snapshot = sharedMessage->getHistorySnapshot(); + historyEditor.setText(sharedMessage->getHistoryText(), false); + historyEditor.moveCaretToEnd(); + lastRevisionSeen = snapshot.revision; + lastTrimRevisionSeen = snapshot.trimRevision; + lastClearRevisionSeen = snapshot.clearRevision; + + sharedMessage->addChangeListener(this); + } + + ~StatusHistoryBox() override { sharedMessage->removeChangeListener(this); } + + void paint(Graphics& g) override + { + g.setColour(Colour(0x44, 0x44, 0x44)); + g.drawRect(getLocalBounds(), 1); + } + + void resized() override { historyEditor.setBounds(getLocalBounds()); } + + void changeListenerCallback(ChangeBroadcaster* /*source*/) override + { + StatusHistorySnapshot snapshot = sharedMessage->getHistorySnapshot(); + + bool trimChanged = snapshot.trimRevision != lastTrimRevisionSeen; + bool clearChanged = snapshot.clearRevision != lastClearRevisionSeen; + bool shouldRebuild = (snapshot.revision <= lastRevisionSeen) || trimChanged || clearChanged + || snapshot.revision != (lastRevisionSeen + 1); + + if (shouldRebuild) + { + historyEditor.setText(sharedMessage->getHistoryText(), false); + } + else if (snapshot.lastEntry.isNotEmpty()) + { + if (historyEditor.getText().isNotEmpty()) + { + historyEditor.insertTextAtCaret("\n"); + } + + historyEditor.insertTextAtCaret(snapshot.lastEntry); + } + + lastRevisionSeen = snapshot.revision; + lastTrimRevisionSeen = snapshot.trimRevision; + lastClearRevisionSeen = snapshot.clearRevision; + historyEditor.moveCaretToEnd(); + } + +private: + SharedResourcePointer sharedMessage; + TextEditor historyEditor; + uint64 lastRevisionSeen = 0; + uint64 lastTrimRevisionSeen = 0; + uint64 lastClearRevisionSeen = 0; +}; + class StatusAreaWidget : public Component { public: StatusAreaWidget() { addAndMakeVisible(instructionsBox); - addAndMakeVisible(statusBox); + addAndMakeVisible(statusHistoryBox); } - ~StatusAreaWidget() {} + ~StatusAreaWidget() override {} void resized() override { @@ -92,7 +142,7 @@ class StatusAreaWidget : public Component statusArea.flexDirection = FlexBox::Direction::row; statusArea.items.add(FlexItem(instructionsBox).withFlex(1).withMargin(marginSize)); - statusArea.items.add(FlexItem(statusBox).withFlex(1).withMargin(marginSize)); + statusArea.items.add(FlexItem(statusHistoryBox).withFlex(1).withMargin(marginSize)); statusArea.performLayout(getLocalBounds()); } @@ -101,5 +151,5 @@ class StatusAreaWidget : public Component const float marginSize = 2; InstructionsBox instructionsBox; - StatusBox statusBox; + StatusHistoryBox statusHistoryBox; }; From 89b5c38a193d5db6f4a76149bce2fbdf6a610b18 Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Thu, 19 Mar 2026 11:47:21 -0500 Subject: [PATCH 02/18] gradio info error handling and non-modal error popup --- src/ModelTab.h | 177 +++++++++++++++++++++++++-------- src/clients/GradioClient.h | 129 +++++++++++++++++++++++- src/utils/Errors.h | 8 +- src/widgets/StatusAreaWidget.h | 2 +- 4 files changed, 267 insertions(+), 49 deletions(-) diff --git a/src/ModelTab.h b/src/ModelTab.h index e6e8e1d0..081e3921 100644 --- a/src/ModelTab.h +++ b/src/ModelTab.h @@ -17,6 +17,7 @@ #include "utils/Errors.h" #include "utils/Logging.h" +#include "utils/Settings.h" #include "utils/Tutorial.h" using namespace juce; @@ -327,79 +328,168 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas void openErrorPopup(const Error error, std::function onExit = {}) { - MessageBoxOptions errorPopup = - MessageBoxOptions() - .withIconType(AlertWindow::WarningIcon) - .withTitle("Error") // TODO - Name of error family would be nice here - // error ? toUserMessage(*error) : "An unknown error occurred." - .withMessage(toUserMessage(error)); - std::optional openablePath = getOpenablePath(error); + String errorMessage = toUserMessage(error); - if (openablePath.has_value()) + constexpr int buttonOpenURL = 1; + constexpr int buttonOpenLogs = 2; + constexpr int buttonSendReport = 3; + constexpr int buttonOk = 0; + + String popupMessage = errorMessage + + "\n\nOptional: add context below before sending the report."; + + if (errorPopupWindow != nullptr) { - errorPopup = errorPopup.withButton("Open URL"); + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); } - errorPopup = errorPopup.withButton("Open Logs").withButton("Ok"); + errorPopupWindow = std::make_unique("Error", popupMessage, AlertWindow::WarningIcon); + AlertWindow* alertWindow = errorPopupWindow.get(); - auto alertCallback = [this, error, openablePath, onExit, errorPopup](int choice) + auto bindButtonCallback = [alertWindow](const String& buttonText, std::function callback) { - DBG_AND_LOG("ModelTab::loadModelCallback::alertCallback: Chose button index: " << choice - << "."); - - enum Choice + for (int i = 0; i < alertWindow->getNumChildComponents(); ++i) { - OpenURL, - OpenLogs, - OK - }; - - /* - TODO - The button indices assigned by MessageBoxOptions do not follow the order in which - they were added. This should be fixed in JUCE v8. The following is a temporary workaround. - - See https://forum.juce.com/t/wrong-callback-value-for-alertwindow-showokcancelbox/55671/2 + if (auto* button = dynamic_cast(alertWindow->getChildComponent(i))) + { + if (button->getButtonText() == buttonText) + { + button->onClick = std::move(callback); + return; + } + } + } + }; - When this is fixed, errorPopup can be removed from the argument list. - */ + if (openablePath.has_value()) + { + alertWindow->addButton("Open URL", buttonOpenURL); + bindButtonCallback("Open URL", [this, openablePath, onExit] { - std::map observedButtonIndicesMap = {}; + URL(*openablePath).launchInDefaultBrowser(); - if (errorPopup.getNumButtons() == 3) + if (onExit) { - observedButtonIndicesMap.insert({ 1, Choice::OpenURL }); + onExit(); } - observedButtonIndicesMap.insert( - { errorPopup.getNumButtons() - 1, Choice::OpenLogs }); + if (errorPopupWindow != nullptr) + { + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); + } + }); + } - observedButtonIndicesMap.insert({ 0, Choice::OK }); + alertWindow->addButton("Open Logs", buttonOpenLogs); + bindButtonCallback("Open Logs", [this, onExit] + { + HARPLogger::getInstance()->getLogFile().revealToUser(); - choice = observedButtonIndicesMap[choice]; + if (onExit) + { + onExit(); } - if (choice == Choice::OpenURL) + if (errorPopupWindow != nullptr) { - URL(*openablePath).launchInDefaultBrowser(); + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); } - else if (choice == Choice::OpenLogs) + }); + + alertWindow->addButton("Send Error Log to TeamUP", buttonSendReport); + bindButtonCallback("Send Error Log to TeamUP", [this, error, errorMessage, onExit] + { + if (errorPopupWindow != nullptr) { - HARPLogger::getInstance()->getLogFile().revealToUser(); + sendErrorReportEmail( + error, errorMessage, errorPopupWindow->getTextEditorContents("errorReportNotes")); } - else + + if (onExit) { - // Nothing to do + onExit(); + } + + if (errorPopupWindow != nullptr) + { + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); } + }); + alertWindow->addButton("Ok", buttonOk); + bindButtonCallback("Ok", [this, onExit] + { if (onExit) { - // Perform optional state cleanup onExit(); } - }; - AlertWindow::showAsync(errorPopup, alertCallback); + if (errorPopupWindow != nullptr) + { + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); + } + }); + + alertWindow->addTextEditor( + "errorReportNotes", + "", + "What were you doing when this happened? (optional)"); + + addAndMakeVisible(*errorPopupWindow); + errorPopupWindow->setAlwaysOnTop(true); + + int popupWidth = jmax(560, jmin(getWidth() - 24, 1100)); + int popupHeight = 280; + errorPopupWindow->setBounds(getLocalBounds().withSizeKeepingCentre(popupWidth, popupHeight)); + errorPopupWindow->toFront(true); + } + + void sendErrorReportEmail(const Error& error, const String& errorMessage, const String& notes) + { + String supportEmail = Settings::getString("supportEmail", "support@teamup.tech").trim(); + + if (supportEmail.isEmpty()) + { + AlertWindow::showMessageBoxAsync( + AlertWindow::WarningIcon, + "Missing Support Email", + "Unable to send report because support email is not configured.", + "Ok"); + return; + } + + File logFile = HARPLogger::getInstance()->getLogFile(); + + String body; + body << "Please investigate this HARP error.\n\n"; + body << "Error:\n" << errorMessage << "\n\n"; + + if (const auto* gradioError = std::get_if(&error)) + { + if (gradioError->endpointPath.isNotEmpty()) + { + body << "Endpoint:\n" << gradioError->endpointPath << "\n\n"; + } + } + + body << "User notes:\n" << (notes.isNotEmpty() ? notes : "(none)") << "\n\n"; + body << "Log file:\n" << logFile.getFullPathName() << "\n\n"; + body << "Please attach the log file from the path above when sending."; + + String subject = "HARP Error Report"; + + String escapedSubject = URL::addEscapeChars(subject, true); + String escapedBody = URL::addEscapeChars(body, true); + String escapedRecipient = URL::addEscapeChars(supportEmail, true); + + URL("mailto:" + escapedRecipient + "?subject=" + escapedSubject + "&body=" + escapedBody) + .launchInDefaultBrowser(); } void loadModelCallback() @@ -603,4 +693,5 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas ThreadPool processingThreadPool { 10 }; std::atomic currentProcessID { 0 }; + std::unique_ptr errorPopupWindow; }; diff --git a/src/clients/GradioClient.h b/src/clients/GradioClient.h index 5f144ed8..ff4ed03b 100644 --- a/src/clients/GradioClient.h +++ b/src/clients/GradioClient.h @@ -586,6 +586,29 @@ class GradioClient : public Client if (statusCode != 200) { + String diagnosticText = extractErrorTextFromPayload(response); + + if (diagnosticText.isNotEmpty()) + { + String reason = extractExceptionLabel(diagnosticText); + + if (reason.isEmpty()) + { + reason = extractShortReason(diagnosticText); + } + + if (statusMessage != nullptr && reason.isNotEmpty()) + { + statusMessage->setMessage("[error] " + reason); + } + + GradioError::Type errorType = + isExplicitQuotaError(diagnosticText) ? GradioError::Type::QuotaExceeded + : GradioError::Type::RuntimeError; + + return OpResult::fail(GradioError { errorType, errorPath, reason }); + } + return OpResult::fail(HttpError { HttpError::Type::BadStatusCode, HttpError::Request::POST, errorPath, statusCode }); } @@ -827,6 +850,90 @@ class GradioClient : public Client return ""; } + static String extractFirstNonEmptyField( + const DynamicObject* object, std::initializer_list keys) + { + if (object == nullptr) + { + return ""; + } + + for (auto key : keys) + { + Identifier id(key); + + if (! object->hasProperty(id)) + { + continue; + } + + String value = object->getProperty(id).toString().trim(); + + if (value.isNotEmpty() && ! value.equalsIgnoreCase("null") + && ! value.equalsIgnoreCase("undefined")) + { + return value; + } + } + + return ""; + } + + static String extractGradioAlertStatusFromPayload(const String& payload) + { + String normalizedPayload = payload.trim(); + + if (normalizedPayload.isEmpty() || normalizedPayload.equalsIgnoreCase("null") + || normalizedPayload.equalsIgnoreCase("none")) + { + return ""; + } + + var parsedPayload = JSON::parse(normalizedPayload); + auto* payloadDict = parsedPayload.getDynamicObject(); + + if (payloadDict == nullptr) + { + return ""; + } + + String severity = + extractFirstNonEmptyField(payloadDict, { "type", "level", "severity", "status" }) + .toLowerCase(); + String message = + extractFirstNonEmptyField(payloadDict, { "message", "detail", "error", "reason" }); + + if (message.isEmpty()) + { + var outputVar = payloadDict->getProperty("output"); + + if (auto* outputDict = outputVar.getDynamicObject()) + { + message = + extractFirstNonEmptyField(outputDict, { "message", "detail", "error", "reason" }); + } + } + + if (message.isEmpty()) + { + return ""; + } + + if (severity.isEmpty() || severity == "success") + { + if (message.containsIgnoreCase("error")) + { + severity = "error"; + } + else + { + severity = "info"; + } + } + + return "[" + severity + "] " + message; + } + static String formatProcessMessage(const String& messageType) { if (messageType.isEmpty()) @@ -863,7 +970,17 @@ class GradioClient : public Client String payload = extractPayload(dataLine); String messageType = extractMessageTypeFromPayload(payload); - String statusText = formatProcessMessage(messageType); + String statusText; + + if (messageType.isNotEmpty()) + { + statusText = formatProcessMessage(messageType); + } + + if (statusText.isEmpty()) + { + statusText = extractGradioAlertStatusFromPayload(payload); + } if (statusText.isEmpty()) { @@ -945,6 +1062,16 @@ class GradioClient : public Client isExplicitQuotaError(classificationText) ? GradioError::Type::QuotaExceeded : GradioError::Type::RuntimeError; + if (statusMessage != nullptr) + { + String statusErrorText = reasonText.isNotEmpty() ? reasonText : classificationText; + + if (statusErrorText.isNotEmpty()) + { + statusMessage->setMessage("[error] " + extractShortReason(statusErrorText)); + } + } + return OpResult::fail(GradioError { errorType, errorPath, reason }); } else if (eventLine.startsWithIgnoreCase("data:")) diff --git a/src/utils/Errors.h b/src/utils/Errors.h index 6a905457..1b2795dc 100644 --- a/src/utils/Errors.h +++ b/src/utils/Errors.h @@ -168,9 +168,9 @@ inline String toUserMessage(const HttpError& e) if (e.request == HttpError::Request::POST) { - userMessage += " If this is a valid Hugging Face space, this " - "could indicate the space is sleeping or restarting. " - "Please try again in a few minutes."; + userMessage += " This may be a temporary connection issue. " + "Click on Open URL to get the space restarted. " + "If it persists, open the Space and check its logs/status."; } return userMessage; @@ -210,7 +210,7 @@ inline String toUserMessage(const HttpError& e) if (e.statusCode == 503) { userMessage += " If this is a valid Hugging Face space, this could indicate " - "the space is paused or down due to a build or runtime error."; + "the space is sleeping, paused, or down due to a build/runtime error."; } } diff --git a/src/widgets/StatusAreaWidget.h b/src/widgets/StatusAreaWidget.h index 3693e815..bea6891c 100644 --- a/src/widgets/StatusAreaWidget.h +++ b/src/widgets/StatusAreaWidget.h @@ -60,7 +60,7 @@ class StatusHistoryBox : public Component, ChangeListener { historyEditor.setMultiLine(true); historyEditor.setReadOnly(true); - historyEditor.setScrollbarsShown(false); + historyEditor.setScrollbarsShown(true); historyEditor.setCaretVisible(false); historyEditor.setPopupMenuEnabled(false); historyEditor.setColour(TextEditor::backgroundColourId, Colour(0x33, 0x33, 0x33)); From b7f91e776a3de96632a47c18330a3807bce92def Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Fri, 20 Mar 2026 09:37:23 -0500 Subject: [PATCH 03/18] Replace email error reporting with gitHub template --- .../ISSUE_TEMPLATE/runtime_error_report.md | 29 ++++++++ src/ModelTab.h | 66 +++++++++++-------- 2 files changed, 67 insertions(+), 28 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/runtime_error_report.md diff --git a/.github/ISSUE_TEMPLATE/runtime_error_report.md b/.github/ISSUE_TEMPLATE/runtime_error_report.md new file mode 100644 index 00000000..b1ac4e5c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/runtime_error_report.md @@ -0,0 +1,29 @@ +--- +name: Runtime error report +about: Report a runtime/model/space error from HARP +title: "HARP runtime error report" +labels: bug +assignees: "" +--- + +## Summary + + +## Endpoint + + +## User Notes + + +## Environment + + +## Reproduction +1. +2. +3. + +## Expected Behavior + +## Actual Behavior + diff --git a/src/ModelTab.h b/src/ModelTab.h index 081e3921..d26fb141 100644 --- a/src/ModelTab.h +++ b/src/ModelTab.h @@ -337,7 +337,7 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas constexpr int buttonOk = 0; String popupMessage = errorMessage - + "\n\nOptional: add context below before sending the report."; + + "\n\nOptional: add context below before opening the issue."; if (errorPopupWindow != nullptr) { @@ -400,12 +400,12 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas } }); - alertWindow->addButton("Send Error Log to TeamUP", buttonSendReport); - bindButtonCallback("Send Error Log to TeamUP", [this, error, errorMessage, onExit] + alertWindow->addButton("Open GitHub Issue", buttonSendReport); + bindButtonCallback("Open GitHub Issue", [this, error, errorMessage, onExit] { if (errorPopupWindow != nullptr) { - sendErrorReportEmail( + openGitHubIssue( error, errorMessage, errorPopupWindow->getTextEditorContents("errorReportNotes")); } @@ -450,45 +450,55 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas errorPopupWindow->toFront(true); } - void sendErrorReportEmail(const Error& error, const String& errorMessage, const String& notes) + void openGitHubIssue(const Error& error, const String& errorMessage, const String& notes) { - String supportEmail = Settings::getString("supportEmail", "support@teamup.tech").trim(); + static const String issueBaseUrl = "https://github.com/TEAMuP-dev/HARP/issues/new"; + static const String issueTemplate = "runtime_error_report.md"; - if (supportEmail.isEmpty()) + File logFile = HARPLogger::getInstance()->getLogFile(); + + String issueTitle = "HARP runtime error report"; + if (const auto* gradioError = std::get_if(&error)) { - AlertWindow::showMessageBoxAsync( - AlertWindow::WarningIcon, - "Missing Support Email", - "Unable to send report because support email is not configured.", - "Ok"); - return; + if (gradioError->reason.isNotEmpty()) + { + issueTitle = "HARP: " + gradioError->reason; + } + else if (gradioError->type == GradioError::Type::QuotaExceeded) + { + issueTitle = "HARP: Hugging Face quota exceeded"; + } } - File logFile = HARPLogger::getInstance()->getLogFile(); - String body; - body << "Please investigate this HARP error.\n\n"; - body << "Error:\n" << errorMessage << "\n\n"; + body << "## Summary\n"; + body << errorMessage << "\n\n"; if (const auto* gradioError = std::get_if(&error)) { if (gradioError->endpointPath.isNotEmpty()) { - body << "Endpoint:\n" << gradioError->endpointPath << "\n\n"; + body << "## Endpoint\n"; + body << gradioError->endpointPath << "\n\n"; } } - body << "User notes:\n" << (notes.isNotEmpty() ? notes : "(none)") << "\n\n"; - body << "Log file:\n" << logFile.getFullPathName() << "\n\n"; - body << "Please attach the log file from the path above when sending."; - - String subject = "HARP Error Report"; - - String escapedSubject = URL::addEscapeChars(subject, true); + body << "## User Notes\n"; + body << (notes.isNotEmpty() ? notes : "(none)") << "\n\n"; + body << "## Environment\n"; + body << "- HARP version: " << JUCE_APPLICATION_VERSION_STRING << "\n"; + body << "- Time (local): " << Time::getCurrentTime().toString(true, true) << "\n"; + body << "- Log file: " << logFile.getFullPathName() << "\n\n"; + body << "## Reproduction\n"; + body << "1. \n"; + body << "2. \n"; + body << "3. \n"; + + String escapedTitle = URL::addEscapeChars(issueTitle, true); String escapedBody = URL::addEscapeChars(body, true); - String escapedRecipient = URL::addEscapeChars(supportEmail, true); - - URL("mailto:" + escapedRecipient + "?subject=" + escapedSubject + "&body=" + escapedBody) + String escapedTemplate = URL::addEscapeChars(issueTemplate, true); + URL(issueBaseUrl + "?template=" + escapedTemplate + "&title=" + escapedTitle + "&body=" + + escapedBody) .launchInDefaultBrowser(); } From 0dc94eb01cc27cea08bb0539988526d10ec0c4be Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Fri, 3 Apr 2026 13:29:14 -0500 Subject: [PATCH 04/18] Stability error handling --- src/utils/Errors.h | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/utils/Errors.h b/src/utils/Errors.h index 1b2795dc..41c8cee4 100644 --- a/src/utils/Errors.h +++ b/src/utils/Errors.h @@ -177,6 +177,24 @@ inline String toUserMessage(const HttpError& e) case HttpError::Type::BadStatusCode: + if (e.statusCode == 402) + { + if (e.endpointPath.containsIgnoreCase("stability.ai")) + { + userMessage = "Stability AI could not complete this request because your usage " + "credits or quota may be exhausted. Please check your Stability " + "account usage/billing and try again."; + } + else + { + userMessage = "This request could not be completed because the service " + "reported payment/quota limits. Please check your account " + "usage/billing and try again."; + } + + return userMessage; + } + userMessage.clear(); if (e.request == HttpError::Request::POST) From ba45d20f68b0aa09168f0bb76f9211e1b72fb7a8 Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Fri, 3 Apr 2026 17:41:41 -0500 Subject: [PATCH 05/18] zerogpu error handling fixes --- src/clients/GradioClient.h | 185 ++++++++++++++++++++----------------- 1 file changed, 100 insertions(+), 85 deletions(-) diff --git a/src/clients/GradioClient.h b/src/clients/GradioClient.h index ff4ed03b..b1515c33 100644 --- a/src/clients/GradioClient.h +++ b/src/clients/GradioClient.h @@ -523,6 +523,45 @@ class GradioClient : public Client return array.size() == 2; } + bool isZeroGPUSpace(const String& modelPath) + { + if (! isValidHuggingFacePath(modelPath)) + return false; + + URL runtimeEndpoint = + URL("https://huggingface.co/api/spaces/" + inferHostSlashModel(modelPath) + "/runtime"); + + std::unique_ptr stream; + + if (makeGETRequestStream(runtimeEndpoint, stream, "", 5000).failed() || stream == nullptr) + return false; + + String responseJSON = stream->readEntireStreamAsString(); + + DynamicObject::Ptr responseDict; + + if (stringJSONToDict(responseJSON, responseDict).failed()) + return false; + + // Navigate: hardware -> current + static const Identifier hardwareKey { "hardware" }; + static const Identifier currentKey { "current" }; + + if (! responseDict->hasProperty(hardwareKey)) + return false; + + var hardwareVar = responseDict->getProperty(hardwareKey); + auto* hardwareDict = hardwareVar.getDynamicObject(); + + if (hardwareDict == nullptr || ! hardwareDict->hasProperty(currentKey)) + return false; + + String hardwareCurrent = hardwareDict->getProperty(currentKey).toString(); + + // ZeroGPU hardware names start with "zero-" (e.g. "zero-a10g", "zero-a100") + return hardwareCurrent.startsWithIgnoreCase("zero-"); + } + OpResult makePOSTRequest(URL endpoint, const String headers, const String body, @@ -590,21 +629,16 @@ class GradioClient : public Client if (diagnosticText.isNotEmpty()) { - String reason = extractExceptionLabel(diagnosticText); - - if (reason.isEmpty()) - { - reason = extractShortReason(diagnosticText); - } + String reason = extractShortReason(diagnosticText); if (statusMessage != nullptr && reason.isNotEmpty()) { statusMessage->setMessage("[error] " + reason); } - GradioError::Type errorType = - isExplicitQuotaError(diagnosticText) ? GradioError::Type::QuotaExceeded - : GradioError::Type::RuntimeError; + GradioError::Type errorType = isExplicitQuotaError(diagnosticText) + ? GradioError::Type::QuotaExceeded + : GradioError::Type::RuntimeError; return OpResult::fail(GradioError { errorType, errorPath, reason }); } @@ -720,7 +754,8 @@ class GradioClient : public Client return false; } - if (text.containsIgnoreCase("exceeded your gpu quota")) + if ((text.containsIgnoreCase("exceeded your") && text.containsIgnoreCase("gpu quota")) + || text.containsIgnoreCase("zerogpu quota exceeded")) { return true; } @@ -747,9 +782,18 @@ class GradioClient : public Client return ""; } - for (auto label : { "NameError", "TypeError", "ImportError", "ModuleNotFoundError", - "ValueError", "KeyError", "AttributeError", "RuntimeError", - "AssertionError", "IndexError", "SyntaxError", "OSError" }) + for (auto label : { "NameError", + "TypeError", + "ImportError", + "ModuleNotFoundError", + "ValueError", + "KeyError", + "AttributeError", + "RuntimeError", + "AssertionError", + "IndexError", + "SyntaxError", + "OSError" }) { if (text.containsIgnoreCase(label)) { @@ -757,8 +801,7 @@ class GradioClient : public Client } } - StringArray tokens = StringArray::fromTokens( - text, " \t\r\n,:;()[]{}<>\"'`\\/|", ""); + StringArray tokens = StringArray::fromTokens(text, " \t\r\n,:;()[]{}<>\"'`\\/|", ""); for (auto& token : tokens) { @@ -786,42 +829,6 @@ class GradioClient : public Client return firstLine; } - String collectErrorContextText(std::unique_ptr& stream, int maxLines = 8) - { - String contextText; - - for (int i = 0; i < maxLines && ! stream->isExhausted(); ++i) - { - String contextLine = stream->readNextLine().trim(); - - if (contextLine.isEmpty()) - { - continue; - } - - if (contextLine.startsWithIgnoreCase("event:")) - { - break; - } - - String normalizedContext = extractErrorTextFromPayload(extractPayload(contextLine)); - - if (normalizedContext.isEmpty()) - { - continue; - } - - if (contextText.isNotEmpty()) - { - contextText += "\n"; - } - - contextText += normalizedContext; - } - - return contextText; - } - static String extractMessageTypeFromPayload(const String& payload) { String normalizedPayload = payload.trim(); @@ -850,8 +857,8 @@ class GradioClient : public Client return ""; } - static String extractFirstNonEmptyField( - const DynamicObject* object, std::initializer_list keys) + static String extractFirstNonEmptyField(const DynamicObject* object, + std::initializer_list keys) { if (object == nullptr) { @@ -909,8 +916,8 @@ class GradioClient : public Client if (auto* outputDict = outputVar.getDynamicObject()) { - message = - extractFirstNonEmptyField(outputDict, { "message", "detail", "error", "reason" }); + message = extractFirstNonEmptyField(outputDict, + { "message", "detail", "error", "reason" }); } } @@ -1005,6 +1012,11 @@ class GradioClient : public Client return result; } + // Track whether ZeroGPU allocated the GPU and started the process. + // If an error arrives before process_starts, it was likely rejected at + // the quota-check stage rather than failing inside the function body. + bool seenProcessStarts = false; + while (! stream->isExhausted()) { response = stream->readNextLine(); @@ -1030,52 +1042,44 @@ class GradioClient : public Client { String errorDataLine = stream->readNextLine(); - DBG_AND_LOG( - "GradioClient::makeGETRequest: Error response \"" << errorDataLine << "\"."); + DBG_AND_LOG("GradioClient::makeGETRequest: Error response \"" << errorDataLine + << "\"."); String payload = extractPayload(errorDataLine); String diagnosticText = extractErrorTextFromPayload(payload); - String contextText = collectErrorContextText(stream); - String classificationText = diagnosticText; + String reason = extractShortReason(diagnosticText); - if (contextText.isNotEmpty()) - { - if (classificationText.isNotEmpty()) - { - classificationText += "\n"; - } + GradioError::Type errorType; - classificationText += contextText; + if (isExplicitQuotaError(diagnosticText)) + { + errorType = GradioError::Type::QuotaExceeded; } - - String reasonText = diagnosticText.isNotEmpty() ? diagnosticText : contextText; - - String reason = extractExceptionLabel(reasonText); - - if (reason.isEmpty() && reasonText.isNotEmpty()) + else if (diagnosticText.isEmpty() && ! seenProcessStarts) { - reason = extractShortReason(reasonText); + errorType = GradioError::Type::QuotaExceeded; } - - GradioError::Type errorType = - isExplicitQuotaError(classificationText) ? GradioError::Type::QuotaExceeded - : GradioError::Type::RuntimeError; - - if (statusMessage != nullptr) + else { - String statusErrorText = reasonText.isNotEmpty() ? reasonText : classificationText; + errorType = GradioError::Type::RuntimeError; + } - if (statusErrorText.isNotEmpty()) - { - statusMessage->setMessage("[error] " + extractShortReason(statusErrorText)); - } + if (statusMessage != nullptr && diagnosticText.isNotEmpty()) + { + statusMessage->setMessage("[error] " + extractShortReason(diagnosticText)); } return OpResult::fail(GradioError { errorType, errorPath, reason }); } else if (eventLine.startsWithIgnoreCase("data:")) { + String dataPayload = extractPayload(eventLine); + if (extractMessageTypeFromPayload(dataPayload).equalsIgnoreCase("process_starts")) + { + seenProcessStarts = true; + } + appendProcessMessageFromDataLine(eventLine); } else @@ -1191,12 +1195,23 @@ class GradioClient : public Client response.clear(); - /* WARNING: it's very important to give Gradio enough time to yield a response + /* Note: it's very important to give Gradio enough time to yield a response (10 seconds was too little for ZeroGPU spaces and led to stream == nullptr) */ result = makeGETRequest(endpoint, response, inferDocumentationPath(modelPath), 120000); if (result.failed()) { + if (const auto* gradioErr = std::get_if(&result.getError())) + { + if (gradioErr->type == GradioError::Type::QuotaExceeded + && gradioErr->reason.isEmpty() && ! isZeroGPUSpace(modelPath)) + { + return OpResult::fail(GradioError { GradioError::Type::RuntimeError, + gradioErr->endpointPath, + gradioErr->reason }); + } + } + return result; } From 47316274cc1e56adb453ab48c640ff21b926add0 Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Fri, 3 Apr 2026 18:05:20 -0500 Subject: [PATCH 06/18] info and warning logs added --- src/clients/GradioClient.h | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/clients/GradioClient.h b/src/clients/GradioClient.h index b1515c33..25f6cc27 100644 --- a/src/clients/GradioClient.h +++ b/src/clients/GradioClient.h @@ -907,8 +907,8 @@ class GradioClient : public Client String severity = extractFirstNonEmptyField(payloadDict, { "type", "level", "severity", "status" }) .toLowerCase(); - String message = - extractFirstNonEmptyField(payloadDict, { "message", "detail", "error", "reason" }); + String message = extractFirstNonEmptyField( + payloadDict, { "message", "detail", "error", "reason", "log" }); if (message.isEmpty()) { @@ -948,6 +948,12 @@ class GradioClient : public Client return ""; } + // "log" messages carry info/warning text + if (messageType.equalsIgnoreCase("log")) + { + return ""; + } + if (messageType.equalsIgnoreCase("process_starts")) { return "[process] started"; From 615886dffbbea3d5089b863293a4c8099596fa7b Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Thu, 16 Apr 2026 19:43:36 -0500 Subject: [PATCH 07/18] restore settings default --- src/MainComponent.cpp | 16 +++++++- src/MainComponent.h | 1 + src/utils/Logging.h | 8 ++++ src/windows/settings/GeneralSettingsTab.cpp | 41 ++++++++++++++++++++- src/windows/settings/GeneralSettingsTab.h | 8 +++- src/windows/settings/SettingsWindow.h | 6 ++- 6 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/MainComponent.cpp b/src/MainComponent.cpp index a31a32df..5787487f 100644 --- a/src/MainComponent.cpp +++ b/src/MainComponent.cpp @@ -207,7 +207,7 @@ void MainComponent::openSettingsWindow() DialogWindow::LaunchOptions options; options.dialogTitle = "Settings"; options.dialogBackgroundColour = Colours::darkgrey; - options.content.setOwned(new SettingsWindow()); + options.content.setOwned(new SettingsWindow([this] { restoreViewDefaults(); })); options.useNativeTitleBar = true; options.resizable = true; @@ -216,6 +216,20 @@ void MainComponent::openSettingsWindow() options.launchAsync(); } +void MainComponent::restoreViewDefaults() +{ + // Default: status area shown — toggle if currently hidden + if (! showStatusArea) + viewStatusAreaCallback(); + + // Default: media clipboard shown — toggle if currently hidden + if (! showMediaClipboard) + viewMediaClipboardCallback(); + + // showWelcomePopup default (true) is already restored by clearing settings; + // it will show on the next launch automatically. +} + /* --View-- */ void MainComponent::viewStatusAreaCallback() diff --git a/src/MainComponent.h b/src/MainComponent.h index b229d7a4..0cfc1ac8 100644 --- a/src/MainComponent.h +++ b/src/MainComponent.h @@ -67,6 +67,7 @@ class MainComponent : public Component, // View void viewStatusAreaCallback(); void viewMediaClipboardCallback(); + void restoreViewDefaults(); // Help void openAboutWindow(); diff --git a/src/utils/Logging.h b/src/utils/Logging.h index e603efb4..9478fd2f 100644 --- a/src/utils/Logging.h +++ b/src/utils/Logging.h @@ -48,6 +48,14 @@ class HARPLogger : private DeletedAtShutdown File getLogFile() const { return logger->getLogFile(); } + void clearLog() + { + File logFile = logger->getLogFile(); + logger.reset(); // release file handle before truncating + logFile.replaceWithText(""); // truncate + initializeLogger(); // reopen + } + private: HARPLogger() = default; // Prevents instantiation from outside diff --git a/src/windows/settings/GeneralSettingsTab.cpp b/src/windows/settings/GeneralSettingsTab.cpp index ff7c2c05..54270a07 100644 --- a/src/windows/settings/GeneralSettingsTab.cpp +++ b/src/windows/settings/GeneralSettingsTab.cpp @@ -1,6 +1,7 @@ #include "GeneralSettingsTab.h" -GeneralSettingsTab::GeneralSettingsTab() +GeneralSettingsTab::GeneralSettingsTab(std::function onRestoreDefaults) + : onRestoreDefaults(std::move(onRestoreDefaults)) { // Set up button to open log folder openLogFolderButton.setButtonText("Open Log Folder"); @@ -11,6 +12,16 @@ GeneralSettingsTab::GeneralSettingsTab() openSettingsButton.setButtonText("Open Settings File"); openSettingsButton.onClick = [this] { handleOpenSettings(); }; addAndMakeVisible(openSettingsButton); + + // Set up button to clear logs + clearLogsButton.setButtonText("Clear Logs"); + clearLogsButton.onClick = [this] { handleClearLogs(); }; + addAndMakeVisible(clearLogsButton); + + // Set up button to restore default settings + restoreDefaultsButton.setButtonText("Restore Default Settings"); + restoreDefaultsButton.onClick = [this] { handleRestoreDefaults(); }; + addAndMakeVisible(restoreDefaultsButton); } void GeneralSettingsTab::resized() @@ -18,10 +29,15 @@ void GeneralSettingsTab::resized() Rectangle area = getLocalBounds().reduced(10); openLogFolderButton.setBounds(area.removeFromTop(30)); + area.removeFromTop(10); - area.removeFromTop(10); // Filler space + clearLogsButton.setBounds(area.removeFromTop(30)); + area.removeFromTop(10); openSettingsButton.setBounds(area.removeFromTop(30)); + area.removeFromTop(10); + + restoreDefaultsButton.setBounds(area.removeFromTop(30)); } void GeneralSettingsTab::handleOpenLogFolder() @@ -40,3 +56,24 @@ void GeneralSettingsTab::handleOpenSettings() // TODO - handler error case } } + +void GeneralSettingsTab::handleClearLogs() +{ + HARPLogger::getInstance()->clearLog(); +} + +void GeneralSettingsTab::handleRestoreDefaults() +{ + if (auto* settings = Settings::getUserSettings()) + { + StringArray allKeys = settings->getAllProperties().getAllKeys(); + + for (const auto& key : allKeys) + settings->removeValue(key); + + settings->saveIfNeeded(); + } + + if (onRestoreDefaults) + onRestoreDefaults(); +} diff --git a/src/windows/settings/GeneralSettingsTab.h b/src/windows/settings/GeneralSettingsTab.h index 2462303b..78287db5 100644 --- a/src/windows/settings/GeneralSettingsTab.h +++ b/src/windows/settings/GeneralSettingsTab.h @@ -16,7 +16,7 @@ using namespace juce; class GeneralSettingsTab : public Component { public: - GeneralSettingsTab(); + GeneralSettingsTab(std::function onRestoreDefaults = {}); ~GeneralSettingsTab() override = default; void resized() override; @@ -24,9 +24,15 @@ class GeneralSettingsTab : public Component private: void handleOpenLogFolder(); void handleOpenSettings(); + void handleClearLogs(); + void handleRestoreDefaults(); + + std::function onRestoreDefaults; TextButton openLogFolderButton; + TextButton clearLogsButton; TextButton openSettingsButton; + TextButton restoreDefaultsButton; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(GeneralSettingsTab) }; diff --git a/src/windows/settings/SettingsWindow.h b/src/windows/settings/SettingsWindow.h index 3bdc9dd2..40de2761 100644 --- a/src/windows/settings/SettingsWindow.h +++ b/src/windows/settings/SettingsWindow.h @@ -17,9 +17,11 @@ using namespace juce; class SettingsWindow : public Component { public: - SettingsWindow() : tabComponent(TabbedButtonBar::TabsAtTop) + SettingsWindow(std::function onRestoreDefaults = {}) + : tabComponent(TabbedButtonBar::TabsAtTop) { - tabComponent.addTab("General", Colours::darkgrey, new GeneralSettingsTab(), true); + tabComponent.addTab( + "General", Colours::darkgrey, new GeneralSettingsTab(onRestoreDefaults), true); tabComponent.addTab("API Keys", Colours::darkgrey, new LoginTab(), true); //tabComponent.addTab("Audio", Colours::darkgrey, new AudioSettingsTab(), true); addAndMakeVisible(tabComponent); From 2e3e81fd942fded7ad9a713be5a4be899d82fb1a Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Fri, 1 May 2026 12:07:52 -0500 Subject: [PATCH 08/18] review changes final --- src/Model.h | 59 ++-- src/ModelTab.h | 342 +++++++++++++++----- src/clients/GradioClient.h | 73 ++--- src/utils/Errors.h | 26 +- src/widgets/StatusAreaWidget.h | 10 +- src/windows/settings/GeneralSettingsTab.cpp | 6 + 6 files changed, 348 insertions(+), 168 deletions(-) diff --git a/src/Model.h b/src/Model.h index eb205086..e5362bbd 100644 --- a/src/Model.h +++ b/src/Model.h @@ -1,7 +1,7 @@ /** * @file Model.h * @brief Model state and interface for loading and processing. - * @author hugofloresgarcia, aldo-aguilar, xribene, cwitkowitz + * @author hugofloresgarcia, aldo-aguilar, xribene, cwitkowitz, saumya-pailwan */ #pragma once @@ -85,19 +85,25 @@ struct ModelMetadata class Model { public: - Model() { resetState(); } - ~Model() { resetState(); } + Model() { resetState(/*suppressStatus=*/true); } + ~Model() { resetState(/*suppressStatus=*/true); } bool isEmpty() { return loadedPath.isEmpty() || client == nullptr; } bool isLoaded() { return ! isEmpty(); } - void setStatus(ModelStatus newStatus) + void setStatus(ModelStatus newStatus, const String& pathContext = "") { status = newStatus; if (statusMessage != nullptr) { - statusMessage->setMessage("Model Status: " + enumToString(status)); + String msg = enumToString(status); + + String shortPath = pathContext.fromLastOccurrenceOf("/", false, false).trim(); + if (shortPath.isNotEmpty()) + msg += " | " + shortPath; + + statusMessage->setMessage(msg); } } @@ -109,7 +115,7 @@ class Model ModelComponentInfoList getInputTracks() { return inputTrackComponents; } ModelComponentInfoList getOutputTracks() { return outputTrackComponents; } - void resetState() + void resetState(bool suppressStatus = false) { metadata = ModelMetadata {}; @@ -124,7 +130,8 @@ class Model loadedPath.clear(); openablePath.clear(); - setStatus(ModelStatus::EMPTY); + if (! suppressStatus) + setStatus(ModelStatus::EMPTY); } OpResult load(String pathToLoad) @@ -137,12 +144,12 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setStatus(ModelStatus::FAILURE, pathToLoad); return result; } - setStatus(ModelStatus::QUERYING_CONTROLS); + setStatus(ModelStatus::QUERYING_CONTROLS, pathToLoad); // Initialize empty dictionary to hold query response DynamicObject::Ptr controls; @@ -152,7 +159,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setStatus(ModelStatus::FAILURE, pathToLoad); return result; } @@ -164,7 +171,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setStatus(ModelStatus::FAILURE, pathToLoad); return result; } @@ -177,7 +184,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setStatus(ModelStatus::FAILURE, pathToLoad); return result; } @@ -189,28 +196,26 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setStatus(ModelStatus::FAILURE, pathToLoad); return result; } - resetState(); + resetState(/*suppressStatus=*/true); - // Update model information if all loading operations are successful metadata = newMetadata; controlComponents = newControls; inputTrackComponents = newInputs; outputTrackComponents = newOutputs; - // Register extracted component IDs orderedInputComponentIDs = std::move(tempComponentIDs); - // Replace existing client + client = std::move(tempClient); - // Keep track of successfully loaded path + loadedPath = pathToLoad; openablePath = client->inferDocumentationPath(loadedPath); - setStatus(ModelStatus::READY); + setStatus(ModelStatus::READY, loadedPath); return OpResult::ok(); } @@ -222,7 +227,7 @@ class Model OpResult result = OpResult::ok(); - setStatus(ModelStatus::PREPARING_REQUEST); + setStatus(ModelStatus::PREPARING_REQUEST, loadedPath); for (auto& fileEntry : inputFiles) { @@ -235,7 +240,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setStatus(ModelStatus::FAILURE, loadedPath); return result; } @@ -331,36 +336,36 @@ class Model DBG_AND_LOG("Model::process: Payload \"" + payloadJSON + "\" prepared for processing."); - setStatus(ModelStatus::PROCESSING); + setStatus(ModelStatus::PROCESSING, loadedPath); result = client->process(loadedPath, payloadJSON, outputFiles, labels); if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setStatus(ModelStatus::FAILURE, loadedPath); return result; } - setStatus(ModelStatus::READY); + setStatus(ModelStatus::READY, loadedPath); return result; } OpResult cancel() { - setStatus(ModelStatus::CANCELING); + setStatus(ModelStatus::CANCELING, loadedPath); OpResult result = client->cancel(loadedPath); if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setStatus(ModelStatus::FAILURE, loadedPath); return result; } - setStatus(ModelStatus::READY); + setStatus(ModelStatus::READY, loadedPath); return OpResult::ok(); } diff --git a/src/ModelTab.h b/src/ModelTab.h index 3331270b..7bb31855 100644 --- a/src/ModelTab.h +++ b/src/ModelTab.h @@ -1,7 +1,7 @@ /** * @file ModelTab.h * @brief Reusable component containing HARP GUI elements and state for a single model. - * @author hugofloresgarcia, xribene, cwitkowitz + * @author hugofloresgarcia, xribene, cwitkowitz, saumya-pailwan */ #pragma once @@ -191,6 +191,23 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas totalTracks); tabArea.performLayout(getLocalBounds()); + + // Re-centre the error popup when the parent is resized so it never + // drifts off-screen or clips against the new bounds. + if (errorPopupWindow != nullptr) + { + Component* topLevel = getTopLevelComponent(); + int windowWidth = (topLevel != nullptr) ? topLevel->getWidth() : getWidth(); + int popupWidth = jmin(520, windowWidth - 24); + int popupHeight = errorPopupWindow->getHeight(); + Point windowCentreScreen = + (topLevel != nullptr) + ? topLevel->localPointToGlobal(topLevel->getLocalBounds().getCentre()) + : localPointToGlobal(getLocalBounds().getCentre()); + Point centreInLocal = getLocalPoint(nullptr, windowCentreScreen); + errorPopupWindow->setBounds( + Rectangle(popupWidth, popupHeight).withCentre(centreInLocal)); + } } int getMinimumRequiredControlWidth() { return controlAreaWidget.getMinimumRequiredWidth(); } @@ -333,8 +350,16 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas constexpr int buttonSendReport = 3; constexpr int buttonOk = 0; - String popupMessage = errorMessage - + "\n\nOptional: add context below before opening the issue."; + // Determine whether this error warrants a GitHub bug report. + // Quota errors, invalid paths, and expected HTTP failures are user-actionable + // and don't need a report; only unexpected runtime/parse errors do. + bool isReportableError = false; + if (const auto* gradioErr = std::get_if(&error)) + isReportableError = (gradioErr->type == GradioError::Type::RuntimeError); + else if (std::get_if(&error) || std::get_if(&error)) + isReportableError = true; + + String popupMessage = errorMessage; if (errorPopupWindow != nullptr) { @@ -342,10 +367,14 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas errorPopupWindow.reset(); } - errorPopupWindow = std::make_unique("Error", popupMessage, AlertWindow::WarningIcon); + errorPopupWindow = std::make_unique( + "Error", popupMessage, AlertWindow::WarningIcon); + centredAlertLF.messageText = popupMessage; + errorPopupWindow->setLookAndFeel(¢redAlertLF); AlertWindow* alertWindow = errorPopupWindow.get(); - auto bindButtonCallback = [alertWindow](const String& buttonText, std::function callback) + auto bindButtonCallback = + [alertWindow](const String& buttonText, std::function callback) { for (int i = 0; i < alertWindow->getNumChildComponents(); ++i) { @@ -363,87 +392,100 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas if (openablePath.has_value()) { alertWindow->addButton("Open URL", buttonOpenURL); - bindButtonCallback("Open URL", [this, openablePath, onExit] - { - URL(*openablePath).launchInDefaultBrowser(); - - if (onExit) - { - onExit(); - } - - if (errorPopupWindow != nullptr) - { - removeChildComponent(errorPopupWindow.get()); - errorPopupWindow.reset(); - } - }); + bindButtonCallback("Open URL", + [this, openablePath, onExit] + { + URL(*openablePath).launchInDefaultBrowser(); + + if (onExit) + { + onExit(); + } + + if (errorPopupWindow != nullptr) + { + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); + } + }); } alertWindow->addButton("Open Logs", buttonOpenLogs); - bindButtonCallback("Open Logs", [this, onExit] + bindButtonCallback("Open Logs", + [this, onExit] + { + HARPLogger::getInstance()->getLogFile().revealToUser(); + + if (onExit) + { + onExit(); + } + + if (errorPopupWindow != nullptr) + { + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); + } + }); + + if (isReportableError) { - HARPLogger::getInstance()->getLogFile().revealToUser(); - - if (onExit) - { - onExit(); - } - - if (errorPopupWindow != nullptr) - { - removeChildComponent(errorPopupWindow.get()); - errorPopupWindow.reset(); - } - }); - - alertWindow->addButton("Open GitHub Issue", buttonSendReport); - bindButtonCallback("Open GitHub Issue", [this, error, errorMessage, onExit] - { - if (errorPopupWindow != nullptr) - { - openGitHubIssue( - error, errorMessage, errorPopupWindow->getTextEditorContents("errorReportNotes")); - } - - if (onExit) - { - onExit(); - } - - if (errorPopupWindow != nullptr) - { - removeChildComponent(errorPopupWindow.get()); - errorPopupWindow.reset(); - } - }); + alertWindow->addButton("Report", buttonSendReport); + bindButtonCallback("Report", + [this, error, errorMessage, onExit] + { + // Opens GitHub new-issue page pre-filled with error details. + openGitHubIssue(error, errorMessage, ""); + + if (onExit) + { + onExit(); + } + + if (errorPopupWindow != nullptr) + { + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); + } + }); + } alertWindow->addButton("Ok", buttonOk); - bindButtonCallback("Ok", [this, onExit] - { - if (onExit) - { - onExit(); - } - - if (errorPopupWindow != nullptr) - { - removeChildComponent(errorPopupWindow.get()); - errorPopupWindow.reset(); - } - }); - - alertWindow->addTextEditor( - "errorReportNotes", - "", - "What were you doing when this happened? (optional)"); + bindButtonCallback("Ok", + [this, onExit] + { + if (onExit) + { + onExit(); + } + + if (errorPopupWindow != nullptr) + { + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); + } + }); addAndMakeVisible(*errorPopupWindow); errorPopupWindow->setAlwaysOnTop(true); - int popupWidth = jmax(560, jmin(getWidth() - 24, 1100)); - int popupHeight = 280; - errorPopupWindow->setBounds(getLocalBounds().withSizeKeepingCentre(popupWidth, popupHeight)); + // Size based on full window width so the popup is never squashed when + // the media clipboard panel is open and ModelTab is narrow. + Component* topLevel = getTopLevelComponent(); + int windowWidth = (topLevel != nullptr) ? topLevel->getWidth() : getWidth(); + int popupWidth = jmin(520, windowWidth - 24); + int popupHeight = isReportableError ? 260 : 230; + + // Find the window's centre in screen space, then convert to ModelTab's + // local coordinate space so the popup is centred in the full window + // regardless of where ModelTab sits within it. + Point windowCentreScreen = + (topLevel != nullptr) + ? topLevel->localPointToGlobal(topLevel->getLocalBounds().getCentre()) + : localPointToGlobal(getLocalBounds().getCentre()); + Point centreInLocal = getLocalPoint(nullptr, windowCentreScreen); + errorPopupWindow->setBounds( + Rectangle(popupWidth, popupHeight).withCentre(centreInLocal)); errorPopupWindow->toFront(true); } @@ -494,8 +536,8 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas String escapedTitle = URL::addEscapeChars(issueTitle, true); String escapedBody = URL::addEscapeChars(body, true); String escapedTemplate = URL::addEscapeChars(issueTemplate, true); - URL(issueBaseUrl + "?template=" + escapedTemplate + "&title=" + escapedTitle + "&body=" - + escapedBody) + URL(issueBaseUrl + "?template=" + escapedTemplate + "&title=" + escapedTitle + + "&body=" + escapedBody) .launchInDefaultBrowser(); } @@ -680,6 +722,147 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas inputTrackAreaWidget.setLoadTrackEnabled(true); } + static constexpr int popupButtonBottomPadding = 24; + + /* LookAndFeel override that draws the AlertWindow message text centred. + We re-derive both from the ACTUAL window height here so that + text fills the real available space and buttons are accounted for at the + bottom where BottomButtonAlertWindow will move them. */ + + struct CentredAlertLookAndFeel : public LookAndFeel_V4 + { + String messageText; // set before showing the popup + + void drawAlertBox(Graphics& g, + AlertWindow& alert, + const Rectangle& /*textArea*/, + TextLayout& /*unused*/) override + { + // Background + auto cornerSize = 4.0f; + g.setColour(alert.findColour(AlertWindow::outlineColourId)); + g.drawRoundedRectangle(alert.getLocalBounds().toFloat(), cornerSize, 2.0f); + + auto bounds = alert.getLocalBounds().reduced(1); + g.reduceClipRegion(bounds); + + g.setColour(alert.findColour(AlertWindow::backgroundColourId)); + g.fillRoundedRectangle(bounds.toFloat(), cornerSize); + + // Icon + auto iconSpaceUsed = 0; + const auto iconWidth = 80; + auto iconSize = jmin(iconWidth + 50, bounds.getHeight() + 20); + + if (alert.containsAnyExtraComponents() || alert.getNumButtons() > 2) + iconSize = jmin(iconSize, 200); + + Rectangle iconRect(iconSize / -10, iconSize / -10, iconSize, iconSize); + + if (alert.getAlertType() != MessageBoxIconType::NoIcon) + { + Path icon; + char character; + uint32 colour; + + if (alert.getAlertType() == MessageBoxIconType::WarningIcon) + { + character = '!'; + icon.addTriangle((float) iconRect.getX() + (float) iconRect.getWidth() * 0.5f, + (float) iconRect.getY(), + (float) iconRect.getRight(), + (float) iconRect.getBottom(), + (float) iconRect.getX(), + (float) iconRect.getBottom()); + icon = icon.createPathWithRoundedCorners(5.0f); + colour = 0x66ff2a00; + } + else + { + colour = Colour(0xff00b0b9).withAlpha(0.4f).getARGB(); + character = alert.getAlertType() == MessageBoxIconType::InfoIcon ? 'i' : '?'; + icon.addEllipse(iconRect.toFloat()); + } + + GlyphArrangement ga; + ga.addFittedText({ (float) iconRect.getHeight() * 0.9f, Font::bold }, + String::charToString((juce_wchar) (uint8) character), + (float) iconRect.getX(), + (float) iconRect.getY(), + (float) iconRect.getWidth(), + (float) iconRect.getHeight(), + Justification::centred, + false); + ga.createPath(icon); + icon.setUsingNonZeroWinding(false); + g.setColour(Colour(colour)); + g.fillPath(icon); + + iconSpaceUsed = iconWidth; + } + + if (messageText.isNotEmpty()) + { + const int buttonH = getAlertWindowButtonHeight(); + const int titleH = 24; + const int edgeGap = 10; + const int bottomOfText = + alert.getHeight() - buttonH - popupButtonBottomPadding - 10; + + const int rightPadding = edgeGap + iconSpaceUsed; + Rectangle fullTextArea( + (float) (edgeGap + iconSpaceUsed), + (float) (edgeGap + titleH), + (float) (alert.getWidth() - edgeGap - iconSpaceUsed - rightPadding), + (float) (bottomOfText - (edgeGap + titleH))); + + Font msgFont(16.0f); + + AttributedString attrStr; + attrStr.setJustification(Justification::topLeft); + attrStr.append(messageText, msgFont, alert.findColour(AlertWindow::textColourId)); + + TextLayout layout; + layout.createLayout(attrStr, fullTextArea.getWidth()); + layout.draw(g, fullTextArea); + } + } + }; + + class BottomButtonAlertWindow : public AlertWindow + { + public: + BottomButtonAlertWindow(const String& title, + const String& message, + MessageBoxIconType iconType) + : AlertWindow(title, message, iconType) + { + } + + void resized() override + { + const int buttonH = getLookAndFeel().getAlertWindowButtonHeight(); + const int targetY = getHeight() - popupButtonBottomPadding - buttonH; + const int spacer = 16; + + Array btns; + for (int i = 0; i < getNumChildComponents(); ++i) + if (auto* btn = dynamic_cast(getChildComponent(i))) + btns.add(btn); + + int totalWidth = -spacer; + for (auto* btn : btns) + totalWidth += btn->getWidth() + spacer; + + int x = (getWidth() - totalWidth) / 2; + for (auto* btn : btns) + { + btn->setTopLeftPosition(x, targetY); + x += btn->getWidth() + spacer; + } + } + }; + static constexpr float marginSize = 2; static constexpr int modelSelectionRowHeight = 30; @@ -709,5 +892,6 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas ThreadPool processingThreadPool { 10 }; std::atomic currentProcessID { 0 }; - std::unique_ptr errorPopupWindow; + CentredAlertLookAndFeel centredAlertLF; + std::unique_ptr errorPopupWindow; }; diff --git a/src/clients/GradioClient.h b/src/clients/GradioClient.h index 25f6cc27..bf171c31 100644 --- a/src/clients/GradioClient.h +++ b/src/clients/GradioClient.h @@ -1,7 +1,7 @@ /** * @file GradioClient.h * @brief Client specifics for Gradio and Hugging Face (simple JSON requests). - * @author xribene, huiranyu, cwitkowitz + * @author xribene, huiranyu, cwitkowitz, saumya-pailwan */ #pragma once @@ -623,6 +623,12 @@ class GradioClient : public Client response = stream->readEntireStreamAsString(); + if (isHTMLResponse(response)) + { + return OpResult::fail(HttpError { + HttpError::Type::BadStatusCode, HttpError::Request::POST, errorPath, 503 }); + } + if (statusCode != 200) { String diagnosticText = extractErrorTextFromPayload(response); @@ -747,6 +753,12 @@ class GradioClient : public Client return normalizedPayload; } + static bool isHTMLResponse(const String& text) + { + String trimmed = text.trimStart(); + return trimmed.startsWithIgnoreCase("\"'`\\/|", ""); - - for (auto& token : tokens) - { - String trimmedToken = token.trim(); - - if (trimmedToken.length() > 5 && trimmedToken.endsWithIgnoreCase("Error") - && ! trimmedToken.equalsIgnoreCase("error")) - { - return trimmedToken; - } - } - - return ""; - } - static String extractShortReason(const String& text) { String firstLine = text.upToFirstOccurrenceOf("\n", false, false).trim(); @@ -1022,6 +992,8 @@ class GradioClient : public Client // If an error arrives before process_starts, it was likely rejected at // the quota-check stage rather than failing inside the function body. bool seenProcessStarts = false; + bool seenAnyDataEvent = false; + bool checkedFirstLine = false; while (! stream->isExhausted()) { @@ -1036,6 +1008,17 @@ class GradioClient : public Client continue; } + if (! checkedFirstLine) + { + checkedFirstLine = true; + + if (isHTMLResponse(eventLine)) + { + return OpResult::fail(HttpError { + HttpError::Type::BadStatusCode, HttpError::Request::GET, errorPath, 503 }); + } + } + if (isEventLine(eventLine, GradioEvents::Complete)) { response = extractPayload(stream->readNextLine()); @@ -1062,7 +1045,7 @@ class GradioClient : public Client { errorType = GradioError::Type::QuotaExceeded; } - else if (diagnosticText.isEmpty() && ! seenProcessStarts) + else if (diagnosticText.isEmpty() && ! seenProcessStarts && ! seenAnyDataEvent) { errorType = GradioError::Type::QuotaExceeded; } @@ -1080,6 +1063,8 @@ class GradioClient : public Client } else if (eventLine.startsWithIgnoreCase("data:")) { + seenAnyDataEvent = true; + String dataPayload = extractPayload(eventLine); if (extractMessageTypeFromPayload(dataPayload).equalsIgnoreCase("process_starts")) { diff --git a/src/utils/Errors.h b/src/utils/Errors.h index 41c8cee4..095b94ab 100644 --- a/src/utils/Errors.h +++ b/src/utils/Errors.h @@ -168,9 +168,9 @@ inline String toUserMessage(const HttpError& e) if (e.request == HttpError::Request::POST) { - userMessage += " This may be a temporary connection issue. " - "Click on Open URL to get the space restarted. " - "If it persists, open the Space and check its logs/status."; + userMessage += + "\n\nThe Space may be sleeping or temporarily unavailable." + "\n\nTry loading again in a few seconds or Click 'Open URL' to open and check it on Huggingface."; } return userMessage; @@ -209,14 +209,7 @@ inline String toUserMessage(const HttpError& e) { } - userMessage += " request to endpoint "; - - if (e.endpointPath.isNotEmpty()) - { - userMessage += "\"" + e.endpointPath + "\" "; - } - - userMessage += "failed"; + userMessage += " request failed"; if (e.statusCode != 0) { @@ -227,8 +220,9 @@ inline String toUserMessage(const HttpError& e) if (e.statusCode == 503) { - userMessage += " If this is a valid Hugging Face space, this could indicate " - "the space is sleeping, paused, or down due to a build/runtime error."; + userMessage += + "\n\nThe Hugging Face Space may be sleeping or paused." + "\n\nTry loading again in a few seconds or Click 'Open URL' to open and check it on Huggingface."; } } @@ -257,14 +251,14 @@ inline String toUserMessage(const GradioError& e) { case GradioError::Type::RuntimeError: - userMessage = "The Hugging Face Space reported a runtime error"; + userMessage = "The Hugging Face Space reported a runtime error."; if (e.reason.isNotEmpty()) { - userMessage += " (" + e.reason + ")"; + userMessage += "\n\nDetails: " + e.reason; } - userMessage += ". Please open the Space logs for details."; + userMessage += "\n\nPlease open the Space logs for more information."; return userMessage; diff --git a/src/widgets/StatusAreaWidget.h b/src/widgets/StatusAreaWidget.h index bea6891c..8133a43b 100644 --- a/src/widgets/StatusAreaWidget.h +++ b/src/widgets/StatusAreaWidget.h @@ -63,8 +63,12 @@ class StatusHistoryBox : public Component, ChangeListener historyEditor.setScrollbarsShown(true); historyEditor.setCaretVisible(false); historyEditor.setPopupMenuEnabled(false); - historyEditor.setColour(TextEditor::backgroundColourId, Colour(0x33, 0x33, 0x33)); + // Transparent background so the parent's paint() fill shows through, + // giving the same appearance as the InstructionsBox (which uses a plain Label). + historyEditor.setColour(TextEditor::backgroundColourId, Colours::transparentBlack); historyEditor.setColour(TextEditor::textColourId, Colour(0xE0, 0xE0, 0xE0)); + historyEditor.setColour(TextEditor::outlineColourId, Colours::transparentBlack); + historyEditor.setColour(TextEditor::focusedOutlineColourId, Colours::transparentBlack); addAndMakeVisible(historyEditor); @@ -82,6 +86,8 @@ class StatusHistoryBox : public Component, ChangeListener void paint(Graphics& g) override { + g.setColour(Colour(0x33, 0x33, 0x33)); + g.fillAll(); g.setColour(Colour(0x44, 0x44, 0x44)); g.drawRect(getLocalBounds(), 1); } @@ -141,8 +147,8 @@ class StatusAreaWidget : public Component FlexBox statusArea; statusArea.flexDirection = FlexBox::Direction::row; - statusArea.items.add(FlexItem(instructionsBox).withFlex(1).withMargin(marginSize)); statusArea.items.add(FlexItem(statusHistoryBox).withFlex(1).withMargin(marginSize)); + statusArea.items.add(FlexItem(instructionsBox).withFlex(1).withMargin(marginSize)); statusArea.performLayout(getLocalBounds()); } diff --git a/src/windows/settings/GeneralSettingsTab.cpp b/src/windows/settings/GeneralSettingsTab.cpp index 54270a07..55a53915 100644 --- a/src/windows/settings/GeneralSettingsTab.cpp +++ b/src/windows/settings/GeneralSettingsTab.cpp @@ -76,4 +76,10 @@ void GeneralSettingsTab::handleRestoreDefaults() if (onRestoreDefaults) onRestoreDefaults(); + + AlertWindow::showMessageBoxAsync( + AlertWindow::InfoIcon, + "Settings Restored", + "All settings (except API tokens) have been restored to their defaults.", + "Ok"); } From ae1cc85919d60f382dd4a401f957f65f29c652c6 Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Fri, 1 May 2026 19:47:15 -0500 Subject: [PATCH 09/18] keep popup open until ok --- src/ModelTab.h | 47 ++++------------------------------------------- 1 file changed, 4 insertions(+), 43 deletions(-) diff --git a/src/ModelTab.h b/src/ModelTab.h index 7bb31855..22d14d92 100644 --- a/src/ModelTab.h +++ b/src/ModelTab.h @@ -393,60 +393,21 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas { alertWindow->addButton("Open URL", buttonOpenURL); bindButtonCallback("Open URL", - [this, openablePath, onExit] - { - URL(*openablePath).launchInDefaultBrowser(); - - if (onExit) - { - onExit(); - } - - if (errorPopupWindow != nullptr) - { - removeChildComponent(errorPopupWindow.get()); - errorPopupWindow.reset(); - } - }); + [openablePath] { URL(*openablePath).launchInDefaultBrowser(); }); } alertWindow->addButton("Open Logs", buttonOpenLogs); bindButtonCallback("Open Logs", - [this, onExit] - { - HARPLogger::getInstance()->getLogFile().revealToUser(); - - if (onExit) - { - onExit(); - } - - if (errorPopupWindow != nullptr) - { - removeChildComponent(errorPopupWindow.get()); - errorPopupWindow.reset(); - } - }); + [] { HARPLogger::getInstance()->getLogFile().revealToUser(); }); if (isReportableError) { alertWindow->addButton("Report", buttonSendReport); bindButtonCallback("Report", - [this, error, errorMessage, onExit] + [this, error, errorMessage] { - // Opens GitHub new-issue page pre-filled with error details. + // Open GitHub issue but keep the popup open openGitHubIssue(error, errorMessage, ""); - - if (onExit) - { - onExit(); - } - - if (errorPopupWindow != nullptr) - { - removeChildComponent(errorPopupWindow.get()); - errorPopupWindow.reset(); - } }); } From 53e2ddb69e50e038211265201e751f1f445bb36f Mon Sep 17 00:00:00 2001 From: Saumya Pailwan Date: Sun, 17 May 2026 23:33:36 -0500 Subject: [PATCH 10/18] review changes: improved on error reporting, SSE log handling, and UI fixes --- src/Model.h | 37 +++++-- src/clients/GradioClient.h | 112 +++++++++++++++++--- src/utils/Errors.h | 21 ++-- src/utils/Logging.h | 9 +- src/widgets/ModelSelectionWidget.h | 27 ++++- src/windows/settings/GeneralSettingsTab.cpp | 13 +++ 6 files changed, 179 insertions(+), 40 deletions(-) diff --git a/src/Model.h b/src/Model.h index e5362bbd..c323c4c7 100644 --- a/src/Model.h +++ b/src/Model.h @@ -107,6 +107,27 @@ class Model } } + void setFailure(const OpResult& result, const String& pathContext = "") + { + status = ModelStatus::FAILURE; + + if (statusMessage != nullptr) + { + String errMsg = toUserMessage(result.getError()); + // Keep only the first line so the status bar stays readable + String shortMsg = errMsg.upToFirstOccurrenceOf("\n", false, false).trim(); + if (shortMsg.isEmpty()) + shortMsg = errMsg.trim(); + + String shortPath = pathContext.fromLastOccurrenceOf("/", false, false).trim(); + String msg = "[error] " + shortMsg; + if (shortPath.isNotEmpty()) + msg += " | " + shortPath; + + statusMessage->setMessage(msg); + } + } + String getLoadedPath() { return loadedPath; } String getOpenablePath() { return openablePath; } @@ -144,7 +165,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE, pathToLoad); + setFailure(result, pathToLoad); return result; } @@ -159,7 +180,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE, pathToLoad); + setFailure(result, pathToLoad); return result; } @@ -171,7 +192,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE, pathToLoad); + setFailure(result, pathToLoad); return result; } @@ -184,7 +205,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE, pathToLoad); + setFailure(result, pathToLoad); return result; } @@ -196,7 +217,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE, pathToLoad); + setFailure(result, pathToLoad); return result; } @@ -240,7 +261,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE, loadedPath); + setFailure(result, loadedPath); return result; } @@ -342,7 +363,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE, loadedPath); + setFailure(result, loadedPath); return result; } @@ -360,7 +381,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE, loadedPath); + setFailure(result, loadedPath); return result; } diff --git a/src/clients/GradioClient.h b/src/clients/GradioClient.h index bf171c31..09823686 100644 --- a/src/clients/GradioClient.h +++ b/src/clients/GradioClient.h @@ -911,17 +911,56 @@ class GradioClient : public Client return "[" + severity + "] " + message; } - static String formatProcessMessage(const String& messageType) + // Formats a "log" SSE event payload (e.g. {"log": "...", "level": "info"}) + // into a user-visible status string like "[info] Starting inference...". + // Returns empty string if the payload is not a valid log event. + static String formatLogEventPayload(const String& payload) + { + String normalizedPayload = payload.trim(); + + if (normalizedPayload.isEmpty() || normalizedPayload.equalsIgnoreCase("null")) + { + return ""; + } + + var parsed = JSON::parse(normalizedPayload); + auto* dict = parsed.getDynamicObject(); + + if (dict == nullptr) + { + return ""; + } + + String message = extractFirstNonEmptyField(dict, { "log", "message", "detail" }); + + if (message.isEmpty()) + { + return ""; + } + + String level = + extractFirstNonEmptyField(dict, { "level", "type", "severity" }).toLowerCase(); + + if (level.isEmpty()) + { + level = "info"; + } + + return "[" + level + "] " + message; + } + + static String formatProcessMessage(const String& messageType, const String& dataPayload = "") { if (messageType.isEmpty()) { return ""; } - // "log" messages carry info/warning text + // Modern Gradio "log" SSE event: parse the data payload for message + level. + // Emitted when the server calls gr.Info() or gr.Warning(). if (messageType.equalsIgnoreCase("log")) { - return ""; + return formatLogEventPayload(dataPayload); } if (messageType.equalsIgnoreCase("process_starts")) @@ -941,35 +980,67 @@ class GradioClient : public Client return "[process] " + detail; } + if (messageType.equalsIgnoreCase("heartbeat") || messageType.equalsIgnoreCase("complete") + || messageType.equalsIgnoreCase("error")) + { + return ""; + } + return "[status] " + messageType.replace("_", " "); } - void appendProcessMessageFromDataLine(const String& dataLine) + // currentSseEventType: the value from the preceding "event: " line, e.g. "log", + // "process_starts", "heartbeat". Empty if no event: line preceded this. + void appendProcessMessageFromDataLine(const String& dataLine, + const String& currentSseEventType = "") { if (statusMessage == nullptr) { + DBG_AND_LOG( + "GradioClient::appendProcessMessageFromDataLine: statusMessage is null, skipping."); return; } String payload = extractPayload(dataLine); - String messageType = extractMessageTypeFromPayload(payload); String statusText; + // Prefer the SSE event type from the preceding "event:" line (modern Gradio API). + // Fall back to extracting a "msg" field from the JSON payload (old queue API). + String messageType = currentSseEventType.isNotEmpty() + ? currentSseEventType + : extractMessageTypeFromPayload(payload); + + DBG_AND_LOG("GradioClient::appendProcessMessageFromDataLine: payload=\"" + << payload << "\" sseEventType=\"" << currentSseEventType << "\" messageType=\"" + << messageType << "\"."); + if (messageType.isNotEmpty()) { - statusText = formatProcessMessage(messageType); + // For "log" events, pass the full data payload so we can extract the message text. + statusText = formatProcessMessage(messageType, payload); + + DBG_AND_LOG("GradioClient::appendProcessMessageFromDataLine: formatProcessMessage -> \"" + << statusText << "\"."); } if (statusText.isEmpty()) { statusText = extractGradioAlertStatusFromPayload(payload); + + DBG_AND_LOG( + "GradioClient::appendProcessMessageFromDataLine: extractGradioAlertStatus -> \"" + << statusText << "\"."); } if (statusText.isEmpty()) { + DBG_AND_LOG( + "GradioClient::appendProcessMessageFromDataLine: statusText is empty, message dropped."); return; } + DBG_AND_LOG("GradioClient::appendProcessMessageFromDataLine: setting statusMessage -> \"" + << statusText << "\"."); statusMessage->setMessage(statusText); } @@ -995,6 +1066,10 @@ class GradioClient : public Client bool seenAnyDataEvent = false; bool checkedFirstLine = false; + // Track the most recent SSE "event: " line so we can pass it to + // appendProcessMessageFromDataLine when the next "data:" line arrives. + String currentSseEventType; + while (! stream->isExhausted()) { response = stream->readNextLine(); @@ -1005,6 +1080,8 @@ class GradioClient : public Client if (eventLine.isEmpty()) { + // Blank line signals the end of one SSE message block + currentSseEventType = ""; continue; } @@ -1019,6 +1096,14 @@ class GradioClient : public Client } } + // Capture the SSE event type from "event: " lines. + if (eventLine.startsWithIgnoreCase("event:")) + { + currentSseEventType = eventLine.fromFirstOccurrenceOf(":", false, false).trim(); + DBG_AND_LOG("GradioClient::makeGETRequest: SSE event type \"" << currentSseEventType + << "\"."); + } + if (isEventLine(eventLine, GradioEvents::Complete)) { response = extractPayload(stream->readNextLine()); @@ -1065,20 +1150,17 @@ class GradioClient : public Client { seenAnyDataEvent = true; + // Check for process_starts using old-API msg field as fallback. String dataPayload = extractPayload(eventLine); - if (extractMessageTypeFromPayload(dataPayload).equalsIgnoreCase("process_starts")) + if (currentSseEventType.equalsIgnoreCase("process_starts") + || extractMessageTypeFromPayload(dataPayload) + .equalsIgnoreCase("process_starts")) { seenProcessStarts = true; } - appendProcessMessageFromDataLine(eventLine); - } - else - { - // TODO - what other information is available? - // Unhandled SSE events (e.g., heartbeat) - // Examples: - // - event: heartbeat + // Pass the current SSE event type so "log" events are not dropped. + appendProcessMessageFromDataLine(eventLine, currentSseEventType); } } diff --git a/src/utils/Errors.h b/src/utils/Errors.h index 095b94ab..695a5a91 100644 --- a/src/utils/Errors.h +++ b/src/utils/Errors.h @@ -157,20 +157,13 @@ inline String toUserMessage(const HttpError& e) { } - userMessage += " request to endpoint"; - - if (e.endpointPath.isNotEmpty()) - { - userMessage += " \"" + e.endpointPath + "\""; - } - - userMessage += "."; + userMessage += " request."; if (e.request == HttpError::Request::POST) { userMessage += - "\n\nThe Space may be sleeping or temporarily unavailable." - "\n\nTry loading again in a few seconds or Click 'Open URL' to open and check it on Huggingface."; + "\n\nThe server may be sleeping or temporarily unavailable." + "\n\nTry loading again in a few seconds, or click 'Open URL' to view the model's page."; } return userMessage; @@ -221,8 +214,8 @@ inline String toUserMessage(const HttpError& e) if (e.statusCode == 503) { userMessage += - "\n\nThe Hugging Face Space may be sleeping or paused." - "\n\nTry loading again in a few seconds or Click 'Open URL' to open and check it on Huggingface."; + "\n\nThe server may be sleeping, paused, or down due to a build or runtime error." + "\n\nTry loading again in a few seconds, or click 'Open URL' to view the model's page."; } } @@ -251,14 +244,14 @@ inline String toUserMessage(const GradioError& e) { case GradioError::Type::RuntimeError: - userMessage = "The Hugging Face Space reported a runtime error."; + userMessage = "The Gradio server reported a runtime error."; if (e.reason.isNotEmpty()) { userMessage += "\n\nDetails: " + e.reason; } - userMessage += "\n\nPlease open the Space logs for more information."; + userMessage += "\n\nClick 'Open URL' to view the model's page for more information."; return userMessage; diff --git a/src/utils/Logging.h b/src/utils/Logging.h index 9478fd2f..5a774b50 100644 --- a/src/utils/Logging.h +++ b/src/utils/Logging.h @@ -51,9 +51,14 @@ class HARPLogger : private DeletedAtShutdown void clearLog() { File logFile = logger->getLogFile(); - logger.reset(); // release file handle before truncating + logger.reset(); // release file handle before truncating logFile.replaceWithText(""); // truncate - initializeLogger(); // reopen + initializeLogger(); // reopen + + File launchLog = + FileLogger::getSystemLogFileFolder().getChildFile("HARP").getChildFile("launch.log"); + if (launchLog.existsAsFile()) + launchLog.replaceWithText(""); } private: diff --git a/src/widgets/ModelSelectionWidget.h b/src/widgets/ModelSelectionWidget.h index bba39217..890079fb 100644 --- a/src/widgets/ModelSelectionWidget.h +++ b/src/widgets/ModelSelectionWidget.h @@ -534,7 +534,32 @@ class ModelSelectionWidget : public Component, public ChangeBroadcaster, public SharedResourcePointer sharedChoices; - ComboBox modelPathComboBox; + // A ComboBox that always opens its popup from the top of the list, showing all + // items without scrolling to the currently-selected one first. + struct FullListComboBox : public ComboBox + { + void showPopup() override + { + auto& lf = getLookAndFeel(); + auto label = std::unique_ptr