diff --git a/src/clients/Client.h b/src/clients/Client.h index 5596eb01..3b1a0384 100644 --- a/src/clients/Client.h +++ b/src/clients/Client.h @@ -10,6 +10,8 @@ #include +#include "../widgets/StatusAreaWidget.h" + #include "../utils/Enums.h" #include "../utils/Errors.h" #include "../utils/Labels.h" @@ -292,6 +294,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..688b28a0 100644 --- a/src/clients/GradioClient.h +++ b/src/clients/GradioClient.h @@ -17,8 +17,10 @@ class GradioClient : public Client { Complete, Heartbeat, - Error - //Generating + Info, + Warning, + Error, + Generating }; GradioClient() @@ -670,42 +672,231 @@ class GradioClient : public Client return result; } + // Collect any info/warning/error logs + StringArray infoLogs; + StringArray warningLogs; + + // Stream the SSE response while (! stream->isExhausted()) { response = stream->readNextLine(); DBG_AND_LOG("GradioClient::makeGETRequest: Streamed response \"" << response << "\"."); - if (response.containsIgnoreCase(enumToString(GradioEvents::Complete))) + if (response.isEmpty()) + { + continue; + } + else if (response.containsIgnoreCase("event: " + enumToString(GradioEvents::Complete))) { response = extractPayLoad(stream->readNextLine()); DBG_AND_LOG("GradioClient::makeGETRequest: Final response \"" << response << "\"."); - break; + return OpResult::ok(); + } + else if (response.containsIgnoreCase("event: " + enumToString(GradioEvents::Info))) + { + response = stream->readNextLine(); + + DBG_AND_LOG("GradioClient::makeGETRequest: Info response \"" << response << "\"."); + + response = extractPayLoad(response); + + DynamicObject::Ptr responseDict; + + result = stringJSONToDict(response, responseDict); + + if (result.failed()) + { + return result; + } + + String msg; + + static const Identifier logKey { "log" }; + static const Identifier msgKey { "message" }; + + if (responseDict->hasProperty(logKey)) + { + msg = responseDict->getProperty(logKey); + } + else if (responseDict->hasProperty(msgKey)) + { + msg = responseDict->getProperty(msgKey); + } + else + { + return OpResult::fail( + JsonError { JsonError::Type::MissingKey, + JSON::toString(var(responseDict.get()), true), + logKey.toString() + " || " + msgKey.toString() }); + } + + if (msg.isNotEmpty()) + { + infoLogs.add(msg); + + // onRemoteMessage(RemoteMessageType::Info, msg); + statusMessage->setMessage("[info] " + msg); + } + } + // TODO - combine with above conditional + else if (response.containsIgnoreCase("event: " + enumToString(GradioEvents::Warning))) + { + response = stream->readNextLine(); + + DBG_AND_LOG("GradioClient::makeGETRequest: Warning response \"" << response + << "\"."); + + response = extractPayLoad(response); + + DynamicObject::Ptr responseDict; + + result = stringJSONToDict(response, responseDict); + + if (result.failed()) + { + return result; + } + + String msg; + + static const Identifier logKey { "log" }; + static const Identifier msgKey { "message" }; + + if (responseDict->hasProperty(logKey)) + { + msg = responseDict->getProperty(logKey); + } + else if (responseDict->hasProperty(msgKey)) + { + msg = responseDict->getProperty(msgKey); + } + else + { + return OpResult::fail( + JsonError { JsonError::Type::MissingKey, + JSON::toString(var(responseDict.get()), true), + logKey.toString() + " || " + msgKey.toString() }); + } + + if (msg.isNotEmpty()) + { + warningLogs.add(msg); + + // onRemoteMessage(RemoteMessageType::Warning, msg); + statusMessage->setMessage("[warning] " + msg); + } } - else if (response.containsIgnoreCase(enumToString(GradioEvents::Error))) + else if (response.containsIgnoreCase("event: " + enumToString(GradioEvents::Error))) { response = stream->readNextLine(); DBG_AND_LOG("GradioClient::makeGETRequest: Error response \"" << response << "\"."); - // TODO - could potentially identify other errors (e.g., too many requests) + /* + response = extractPayLoad(response); + + // Some HF / ZeroGPU paths send "data: null" + bool isNullData = + errorPayload.isEmpty() || errorPayload == "null" || errorPayload == "None"; + + // Read a few more lines for additional context (stack traces, etc.) + juce::String additionalInfo; + const int maxContextLines = 10; + for (int i = 0; i < maxContextLines && ! stream->isExhausted(); ++i) + { + auto nextLine = stream->readNextLine(); + if (nextLine.isEmpty()) + continue; + if (nextLine.startsWith("event:")) + break; // next SSE event, stop + additionalInfo += nextLine + "\n"; + } + additionalInfo = additionalInfo.trim(); + + // Default to remote application error, not HTTP error + error.type = ErrorType::RemoteAppError; + + // interpret errorPayload as JSON + if (! isNullData) + { + var parsed = JSON::parse(errorPayload); + if (parsed.isObject()) + { + if (auto* obj = parsed.getDynamicObject()) + { + juce::String msg; + if (obj->hasProperty("error")) + msg = obj->getProperty("error").toString(); + else if (obj->hasProperty("message")) + msg = obj->getProperty("message").toString(); + else if (obj->hasProperty("detail")) + msg = obj->getProperty("detail").toString(); + + if (msg.isNotEmpty()) + { + error.devMessage = msg; + onRemoteMessage(RemoteMessageType::Error, msg); + } + } + } + + // If JSON parse failed or we didn't get a message, fall back to raw + if (error.devMessage.isEmpty()) + error.devMessage = errorPayload; + } + else + { + // data:null case – we no longer assume "ZeroGPU quota". + // Treat it as a generic remote worker error, but keep any context we have. + if (additionalInfo.isNotEmpty()) + error.devMessage = additionalInfo; + else + error.devMessage = + "The remote worker reported an error with no additional details.\n" + "Open the model's Hugging Face Space to inspect the exact cause."; + } + + // Append any info/warning logs for more context + if (! infoLogs.isEmpty() || ! warningLogs.isEmpty()) + { + juce::String logContext = "\n\n Logs from remote model \n"; + for (auto& msg : infoLogs) + logContext += "[info] " + msg + "\n"; + for (auto& msg : warningLogs) + logContext += "[warning] " + msg + "\n"; + error.devMessage += logContext; + } + */ return OpResult::fail(GradioError { GradioError::Type::RuntimeError, errorPath }); } + else if (response.contains("event: " + enumToString(GradioEvents::Heartbeat))) + { + // HEARTBEAT: keep-alive, ignore + continue; + } + else if (response.contains("event: " + enumToString(GradioEvents::Generating))) + { + // GENERATING / progress – currently we only debug-print + response = stream->readNextLine(); + + DBG_AND_LOG("GradioClient::makeGETRequest: Generating response \"" << response << "\"."); + + continue; + } else { - // TODO - what other information is available? - // Informational or progress events - // Examples: - // - event: heartbeat - // - event: log - // - event: progress + // TODO - handle this case } } - return OpResult::ok(); + // If we exit the loop without a complete or error event, treat as an HTTP-ish error + error.code = statusCode; + error.devMessage = "Did not receive a complete or error event from the remote worker."; + return OpResult::fail(error); } OpResult downloadFile(String downloadPath, File& fileToDownload) //override diff --git a/src/utils/Errors.h b/src/utils/Errors.h index fd9c8318..aafcc3a5 100644 --- a/src/utils/Errors.h +++ b/src/utils/Errors.h @@ -237,6 +237,26 @@ inline String toUserMessage(const GradioError& e) { case GradioError::Type::RuntimeError: + /* + // Make the message a bit more friendly depending on common Python errors + if (error.devMessage.containsIgnoreCase("NameError")) + { + error.userMessage = + "The remote model crashed with a NameError (a variable or function wasn't defined).\n\n" + + error.devMessage; + } + else if (error.devMessage.containsIgnoreCase("TypeError")) + { + error.userMessage = + "The remote model crashed with a TypeError (unexpected type passed in the model code).\n\n" + + error.devMessage; + } + else + { + error.userMessage = "A remote processing error occurred in the model.\n\n" + + error.devMessage; + }*/ + userMessage = "A runtime error occurred at endpoint"; if (e.endpointPath.isNotEmpty()) diff --git a/src/widgets/StatusAreaWidget.h b/src/widgets/StatusAreaWidget.h index 3c7189a3..97ed881a 100644 --- a/src/widgets/StatusAreaWidget.h +++ b/src/widgets/StatusAreaWidget.h @@ -40,11 +40,15 @@ class MessageBox : public Component, ChangeListener public: MessageBox(float fontSize = 15.0f, Justification justification = Justification::centred) { + messageLabel.setJustificationType(juce::Justification::topLeft); messageLabel.setFont(fontSize); - messageLabel.setColour(Label::textColourId, Colour(0xE0, 0xE0, 0xE0)); + messageLabel.setColour(juce::Label::textColourId, juce::Colour(0xE0, 0xE0, 0xE0)); + messageLabel.setInterceptsMouseClicks(false, false); + messageLabel.setMinimumHorizontalScale(1.0f); - messageLabel.setJustificationType(justification); - addAndMakeVisible(messageLabel); + viewport.setViewedComponent(&messageLabel, false); + viewport.setScrollBarsShown(true, false); // vertical yes, horizontal no + addAndMakeVisible(viewport); sharedMessage->addChangeListener(this); } @@ -60,7 +64,13 @@ class MessageBox : public Component, ChangeListener g.drawRect(getLocalBounds(), 1); } - void resized() { messageLabel.setBounds(getLocalBounds()); } + void resized() + { + viewport.setBounds(getLocalBounds()); + + messageLabel.setSize(viewport.getWidth() - viewport.getScrollBarThickness(), + jmax(messageLabel.getHeight(), viewport.getHeight())); + } void changeListenerCallback(ChangeBroadcaster* /*source*/) { @@ -69,9 +79,57 @@ class MessageBox : public Component, ChangeListener private: SharedResourcePointer sharedMessage; + Viewport viewport; Label messageLabel; }; +/* +void StatusBox::appendRemoteMessage(const juce::String& level, + const juce::String& message) +{ + juce::String prefix; + + if (level == "info") prefix = "[info] "; + else if (level == "warning") prefix = "[warning] "; + else if (level == "error") prefix = "[error] "; + + accumulatedMessages << prefix << message << "\n"; + + contentLabel.setText(accumulatedMessages, juce::dontSendNotification); + + // Resize height to fit content + contentLabel.setSize( + viewport.getWidth() - viewport.getScrollBarThickness(), + contentLabel.getTextHeight() + ); + + // Auto scroll to bottom + viewport.setViewPosition( + 0, + juce::jmax(0, contentLabel.getBottom() - viewport.getMaximumVisibleHeight()) + ); + repaint(); +} + +void StatusBox::setStatusMessage(const juce::String& message) +{ + statusLabel.setText(message, juce::dontSendNotification); + accumulatedMessages = message; + contentLabel.setText(message, juce::dontSendNotification); + + contentLabel.setSize( + viewport.getWidth() - viewport.getScrollBarThickness(), + contentLabel.getTextHeight() + ); +} + +void StatusBox::clearStatusMessage() +{ + accumulatedMessages.clear(); + contentLabel.setText({}, juce::dontSendNotification); +} +*/ + using StatusBox = MessageBox; using InstructionsBox = MessageBox;