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/MainComponent.cpp b/src/MainComponent.cpp index 46bf43b9..c319f256 100644 --- a/src/MainComponent.cpp +++ b/src/MainComponent.cpp @@ -212,7 +212,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; @@ -221,6 +221,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 353ec27f..d999a36e 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/Model.h b/src/Model.h index eb205086..c323c4c7 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,46 @@ 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); + } + } + + 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); } } @@ -109,7 +136,7 @@ class Model ModelComponentInfoList getInputTracks() { return inputTrackComponents; } ModelComponentInfoList getOutputTracks() { return outputTrackComponents; } - void resetState() + void resetState(bool suppressStatus = false) { metadata = ModelMetadata {}; @@ -124,7 +151,8 @@ class Model loadedPath.clear(); openablePath.clear(); - setStatus(ModelStatus::EMPTY); + if (! suppressStatus) + setStatus(ModelStatus::EMPTY); } OpResult load(String pathToLoad) @@ -137,12 +165,12 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setFailure(result, pathToLoad); return result; } - setStatus(ModelStatus::QUERYING_CONTROLS); + setStatus(ModelStatus::QUERYING_CONTROLS, pathToLoad); // Initialize empty dictionary to hold query response DynamicObject::Ptr controls; @@ -152,7 +180,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setFailure(result, pathToLoad); return result; } @@ -164,7 +192,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setFailure(result, pathToLoad); return result; } @@ -177,7 +205,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setFailure(result, pathToLoad); return result; } @@ -189,28 +217,26 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setFailure(result, 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 +248,7 @@ class Model OpResult result = OpResult::ok(); - setStatus(ModelStatus::PREPARING_REQUEST); + setStatus(ModelStatus::PREPARING_REQUEST, loadedPath); for (auto& fileEntry : inputFiles) { @@ -235,7 +261,7 @@ class Model if (result.failed()) { - setStatus(ModelStatus::FAILURE); + setFailure(result, loadedPath); return result; } @@ -331,36 +357,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); + setFailure(result, 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); + setFailure(result, loadedPath); return result; } - setStatus(ModelStatus::READY); + setStatus(ModelStatus::READY, loadedPath); return OpResult::ok(); } diff --git a/src/ModelTab.h b/src/ModelTab.h index 3c172ede..a15d9475 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 @@ -17,6 +17,7 @@ #include "utils/Errors.h" #include "utils/Logging.h" +#include "utils/Settings.h" #include "utils/Tutorial.h" using namespace juce; @@ -190,6 +191,8 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas totalTracks); tabArea.performLayout(getLocalBounds()); + + positionErrorPopup(); } int getMinimumRequiredControlWidth() { return controlAreaWidget.getMinimumRequiredWidth(); } @@ -324,79 +327,176 @@ 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; + + // 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) { - 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); + centredAlertLF.messageText = popupMessage; + errorPopupWindow->setLookAndFeel(¢redAlertLF); + 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 - }; + if (auto* button = dynamic_cast(alertWindow->getChildComponent(i))) + { + if (button->getButtonText() == buttonText) + { + button->onClick = std::move(callback); + return; + } + } + } + }; - /* - 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. + if (openablePath.has_value()) + { + alertWindow->addButton("Open URL", buttonOpenURL); + bindButtonCallback("Open URL", + [openablePath] { URL(*openablePath).launchInDefaultBrowser(); }); + } - See https://forum.juce.com/t/wrong-callback-value-for-alertwindow-showokcancelbox/55671/2 + alertWindow->addButton("Open Logs", buttonOpenLogs); + bindButtonCallback("Open Logs", + [] { HARPLogger::getInstance()->getLogFile().revealToUser(); }); - When this is fixed, errorPopup can be removed from the argument list. - */ - { - std::map observedButtonIndicesMap = {}; + if (isReportableError) + { + alertWindow->addButton("Report", buttonSendReport); + bindButtonCallback("Report", + [this, error, errorMessage] + { + // Open GitHub issue but keep the popup open + openGitHubIssue(error, errorMessage, ""); + }); + } - if (errorPopup.getNumButtons() == 3) - { - observedButtonIndicesMap.insert({ 1, Choice::OpenURL }); - } + alertWindow->addButton("Ok", buttonOk); + bindButtonCallback("Ok", + [this, onExit] + { + if (onExit) + { + onExit(); + } + + if (errorPopupWindow != nullptr) + { + removeChildComponent(errorPopupWindow.get()); + errorPopupWindow.reset(); + } + }); + + addAndMakeVisible(*errorPopupWindow); + errorPopupWindow->setAlwaysOnTop(true); + + positionErrorPopup(isReportableError ? 260 : 230); + errorPopupWindow->toFront(true); + } - observedButtonIndicesMap.insert( - { errorPopup.getNumButtons() - 1, Choice::OpenLogs }); + void positionErrorPopup(int desiredHeight = -1) + { + if (errorPopupWindow == nullptr) + { + return; + } - observedButtonIndicesMap.insert({ 0, Choice::OK }); + Component* topLevel = getTopLevelComponent(); + + // Size based on full window width so the popup is never squashed when + // the media clipboard panel is open and ModelTab is narrow. + int windowWidth = (topLevel != nullptr) ? topLevel->getWidth() : getWidth(); + int popupWidth = jmin(520, windowWidth - 24); + int popupHeight = (desiredHeight > 0) ? desiredHeight : errorPopupWindow->getHeight(); + + // 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)); + } - choice = observedButtonIndicesMap[choice]; - } + void openGitHubIssue(const Error& error, const String& errorMessage, const String& notes) + { + static const String issueBaseUrl = "https://github.com/TEAMuP-dev/HARP/issues/new"; + static const String issueTemplate = "runtime_error_report.md"; - if (choice == Choice::OpenURL) - { - URL(*openablePath).launchInDefaultBrowser(); - } - else if (choice == Choice::OpenLogs) + File logFile = HARPLogger::getInstance()->getLogFile(); + + String issueTitle = "HARP runtime error report"; + if (const auto* gradioError = std::get_if(&error)) + { + if (gradioError->reason.isNotEmpty()) { - HARPLogger::getInstance()->getLogFile().revealToUser(); + issueTitle = "HARP: " + gradioError->reason; } - else + else if (gradioError->type == GradioError::Type::QuotaExceeded) { - // Nothing to do + issueTitle = "HARP: Hugging Face quota exceeded"; } + } - if (onExit) + String body; + body << "## Summary\n"; + body << errorMessage << "\n\n"; + + if (const auto* gradioError = std::get_if(&error)) + { + if (gradioError->endpointPath.isNotEmpty()) { - // Perform optional state cleanup - onExit(); + body << "## Endpoint\n"; + body << gradioError->endpointPath << "\n\n"; } - }; + } - AlertWindow::showAsync(errorPopup, alertCallback); + 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 escapedTemplate = URL::addEscapeChars(issueTemplate, true); + URL(issueBaseUrl + "?template=" + escapedTemplate + "&title=" + escapedTitle + + "&body=" + escapedBody) + .launchInDefaultBrowser(); } void loadModelCallback() @@ -580,6 +680,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; @@ -609,4 +850,6 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas ThreadPool processingThreadPool { 10 }; std::atomic currentProcessID { 0 }; -}; \ No newline at end of file + CentredAlertLookAndFeel centredAlertLF; + std::unique_ptr errorPopupWindow; +}; 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..a70589db 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 @@ -18,7 +18,6 @@ class GradioClient : public Client Complete, Heartbeat, Error - //Generating }; GradioClient() @@ -133,7 +132,7 @@ class GradioClient : public Client if (isValidLocalPath(modelPath) || isValidGradioPath(modelPath)) { - documentationPath = inferEndpointPath(documentationPath); + documentationPath = inferEndpointPath(modelPath); } else if (isValidHuggingFacePath(modelPath)) { @@ -524,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, @@ -585,8 +623,40 @@ 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); + + if (diagnosticText.isNotEmpty()) + { + String reason = extractShortReason(diagnosticText); + + if (statusMessage != nullptr) + { + statusMessage->setMessage("[error] " + reason); + } + + // Space-level infra error (build/config failure) — report as 503 + if (isSpaceStatusError(diagnosticText)) + { + return OpResult::fail(HttpError { + HttpError::Type::BadStatusCode, + HttpError::Request::POST, + errorPath, + 503 }); + } + + GradioError::Type errorType = GradioError::Type::RuntimeError; + + return OpResult::fail(GradioError { errorType, errorPath, reason }); + } + return OpResult::fail(HttpError { HttpError::Type::BadStatusCode, HttpError::Request::POST, errorPath, statusCode }); } @@ -643,7 +713,7 @@ class GradioClient : public Client return OpResult::ok(); } - String extractPayLoad(String response) + String extractPayload(String response) { String payload = response.trim(); @@ -655,11 +725,320 @@ 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 isHTMLResponse(const String& text) + { + String trimmed = text.trimStart(); + return trimmed.startsWithIgnoreCase(" 200) + { + firstLine = firstLine.substring(0, 200).trimEnd() + "..."; + } + + return firstLine; + } + + 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 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", "log" }); + + 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; + } + + // 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 ""; + } + + // 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 formatLogEventPayload(dataPayload); + } + + 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; + } + + if (messageType.equalsIgnoreCase("heartbeat") || messageType.equalsIgnoreCase("complete") + || messageType.equalsIgnoreCase("error")) + { + return ""; + } + + return "[status] " + messageType.replace("_", " "); + } + + // 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 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()) + { + // 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); + } + // String response version OpResult makeGETRequest(const URL endpoint, String& response, const String errorPath = "", - const int timeoutMs = -1) + const int timeoutMs = -1, + const String modelPathForQuotaCheck = "") { std::unique_ptr stream; @@ -670,38 +1049,103 @@ class GradioClient : public Client return result; } + 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(); 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()); + // Blank line signals the end of one SSE message block + currentSseEventType = ""; + continue; + } + + if (! checkedFirstLine) + { + checkedFirstLine = true; + + if (isHTMLResponse(eventLine)) + { + return OpResult::fail(HttpError { + HttpError::Type::BadStatusCode, HttpError::Request::GET, errorPath, 503 }); + } + } + + // 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()); 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 reason = extractShortReason(diagnosticText); + + if (statusMessage != nullptr && diagnosticText.isNotEmpty()) + { + statusMessage->setMessage("[error] " + reason); + } - DBG_AND_LOG("GradioClient::makeGETRequest: Error response \"" << response << "\"."); + if (isSpaceStatusError(diagnosticText)) + { + return OpResult::fail(HttpError { + HttpError::Type::BadStatusCode, + HttpError::Request::GET, + errorPath, + 503 }); + } - // TODO - could potentially identify other errors (e.g., too many requests) + GradioError::Type errorType; + + if (diagnosticText.isEmpty() && ! seenAnyDataEvent + && isZeroGPUSpace(modelPathForQuotaCheck)) + { + // ZeroGPU quota rejections arrive before any data: events + // with an empty error payload. Confirmed ZeroGPU space before concluding. + errorType = GradioError::Type::QuotaExceeded; + } + else + { + errorType = GradioError::Type::RuntimeError; + } - return OpResult::fail(GradioError { GradioError::Type::RuntimeError, errorPath }); + return OpResult::fail(GradioError { errorType, errorPath, reason }); } - else + else if (eventLine.startsWithIgnoreCase("data:")) { - // TODO - what other information is available? - // Informational or progress events - // Examples: - // - event: heartbeat - // - event: log - // - event: progress + seenAnyDataEvent = true; + + // Pass the current SSE event type so "log" events are not dropped. + appendProcessMessageFromDataLine(eventLine, currentSseEventType); } } @@ -809,14 +1253,12 @@ 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); + result = makeGETRequest(endpoint, response, inferDocumentationPath(modelPath), 120000, modelPath); if (result.failed()) - { return result; - } return OpResult::ok(); } diff --git a/src/utils/Errors.h b/src/utils/Errors.h index fd9c8318..2b41d452 100644 --- a/src/utils/Errors.h +++ b/src/utils/Errors.h @@ -157,26 +157,37 @@ 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 += " 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 += + "\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; 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) @@ -191,14 +202,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) { @@ -209,8 +213,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 paused or down due to a build or runtime error."; + userMessage += + "\n\nIf this is a valid Hugging Face Space, this could indicate " + "the Space is paused or down due to a build or runtime error."; } } @@ -221,12 +226,14 @@ struct GradioError { enum class Type { - RuntimeError + RuntimeError, + QuotaExceeded }; Type type; String endpointPath; + String reason; }; inline String toUserMessage(const GradioError& e) @@ -237,15 +244,26 @@ inline String toUserMessage(const GradioError& e) { case GradioError::Type::RuntimeError: - userMessage = "A runtime error occurred at endpoint"; + userMessage = "The Gradio server reported a runtime error."; - if (e.endpointPath.isNotEmpty()) + if (e.reason.isNotEmpty()) { - userMessage += " \"" + e.endpointPath + "\""; + userMessage += "\n\nDetails: " + e.reason; } + else + { + userMessage += "\n\nNo error details were provided by the Space." + " Check the Space page for the specific error message."; + } + + userMessage += "\n\nClick 'Open URL' to open the Space on Hugging Face for more information."; + + return userMessage; + + case GradioError::Type::QuotaExceeded: - userMessage += ". If this is a Hugging Face space running on ZeroGPU, this " - "can also indicate a user has exceeded their daily ZeroGPU quota."; + 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/Logging.h b/src/utils/Logging.h index e603efb4..5a774b50 100644 --- a/src/utils/Logging.h +++ b/src/utils/Logging.h @@ -48,6 +48,19 @@ 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 + + File launchLog = + FileLogger::getSystemLogFileFolder().getChildFile("HARP").getChildFile("launch.log"); + if (launchLog.existsAsFile()) + launchLog.replaceWithText(""); + } + private: HARPLogger() = default; // Prevents instantiation from outside diff --git a/src/utils/Messages.h b/src/utils/Messages.h new file mode 100644 index 00000000..e2e57a83 --- /dev/null +++ b/src/utils/Messages.h @@ -0,0 +1,159 @@ +/** + * @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(); + ++snapshot.revision; + ++snapshot.clearRevision; + snapshot.lastEntry.clear(); + } + + sendChangeMessage(); + } + + String getHistoryText() const + { + const ScopedLock lock(messageLock); + return history.joinIntoString("\n"); + } + + StatusHistorySnapshot getHistorySnapshot() + { + const ScopedLock lock(messageLock); + return snapshot; + } + + // Returns history entries added after sinceRevision, in order + // Thread-safe, handles coalesced setMessage() calls correctly + StringArray getEntriesSince(uint64 sinceRevision) const + { + const ScopedLock lock(messageLock); + + if (history.isEmpty()) + return {}; + // history[0] was the (snapshot.revision - history.size() + 1)-th entry added. + // Compute the 0-based index of the first entry we need (revision > sinceRevision). + // startIdx = sinceRevision - (snapshot.revision - history.size()) + // = sinceRevision + history.size() - snapshot.revision + int64 startIdx = + (int64) sinceRevision + (int64) history.size() - (int64) snapshot.revision; + + if (startIdx < 0) + startIdx = 0; // All stored entries are newer than sinceRevision + + if (startIdx >= (int64) history.size()) + return {}; + + StringArray result; + for (int i = (int) startIdx; i < history.size(); ++i) + result.add(history[i]); + return result; + } + +private: + void appendHistoryEntryUnsafe(const String& entryText) + { + if (entryText.isEmpty()) + { + return; + } + + String timestamp = Time::getCurrentTime().formatted("%H:%M:%S"); + + snapshot.lastEntry = "[" + timestamp + "] " + entryText; + history.add(snapshot.lastEntry); + + bool didTrim = false; + + while (history.size() > maxHistoryEntries) + { + history.remove(0); + didTrim = true; + } + + ++snapshot.revision; + + if (didTrim) + { + ++snapshot.trimRevision; + } + } + + StatusHistorySnapshot snapshot; + StringArray history; +}; + +struct InstructionsMessage : SharedMessage +{ +}; diff --git a/src/widgets/ModelSelectionWidget.h b/src/widgets/ModelSelectionWidget.h index bba39217..914d50d1 100644 --- a/src/widgets/ModelSelectionWidget.h +++ b/src/widgets/ModelSelectionWidget.h @@ -534,7 +534,37 @@ 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