Compare commits

...
Author SHA1 Message Date
Brandon Thomas 375ed76f3f forgotten library file 2025-08-16 02:32:41 -04:00
Brandon Thomas 2a09d7001b flash error messages on midjourney errors 2025-08-16 02:32:22 -04:00
16 changed files with 244 additions and 47 deletions
@@ -1,7 +1,7 @@
use crate::error::midjourney_api_error::MidjourneyApiError;
use crate::error::midjourney_client_error::MidjourneyClientError;
use crate::error::midjourney_error::MidjourneyError;
use log::error;
use log::{error, warn};
use serde::{Deserialize, Serialize};
use crate::client::midjourney_hostname::MidjourneyHostname;
@@ -18,7 +18,17 @@ pub struct SubmitJobRequest<'a> {
#[derive(Debug, Clone)]
pub struct SubmitJobResponse {
pub job_id: String,
/// On success, the job ID is returned.
pub maybe_job_id: Option<String>,
/// On error, we have a list of error messages.
pub maybe_errors: Option<Vec<SubmitJobError>>,
}
#[derive(Debug, Clone)]
pub struct SubmitJobError {
pub error_type: Option<String>,
pub message: Option<String>,
}
pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse, MidjourneyError> {
@@ -146,25 +156,45 @@ pub async fn submit_job(req: SubmitJobRequest<'_>) -> Result<SubmitJobResponse,
job_id: String,
}
#[derive(Deserialize)]
struct FailurePayload {
r#type: String,
message: String,
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct RawResponse {
success: Vec<SuccessPayload>,
failure: Option<Vec<FailurePayload>>,
}
let response = serde_json::from_str::<RawResponse>(response_body)
.map_err(|err| MidjourneyApiError::DeserializationError(err))?;
let job_id = match response.success.get(0) {
None => {
error!("No job id found in body: {:?}", response_body);
return Err(MidjourneyApiError::NoJobId.into());
let maybe_job_id = response.success
.get(0)
.map(|s| s.job_id.clone());
if maybe_job_id.is_none() {
warn!("No job id found in body: {:?}", response_body);
}
let mut maybe_errors = None;
if let Some(failures) = response.failure.as_ref() {
if !failures.is_empty() {
maybe_errors = Some(failures.iter()
.map(|f| SubmitJobError {
error_type: Some(f.r#type.clone()),
message: Some(f.message.clone()),
}).collect());
}
Some(item) => item.job_id.clone(),
};
}
Ok(SubmitJobResponse {
job_id,
maybe_job_id,
maybe_errors,
})
}
@@ -6,9 +6,6 @@ use std::io;
#[derive(Debug)]
pub enum MidjourneyApiError {
/// There was no job ID in the otherwise valid-looking response.
NoJobId,
/// There was no user ID in an otherwise valid-looking response.
NoUserId,
@@ -59,9 +56,6 @@ pub enum MidjourneyApiError {
/// A network error occurred.
NetworkError(String),
// /// Uncategorized reqwest error.
// OtherReqwestError(reqwest::Error),
/// An error doing file I/O (on our side)
IoError(io::Error),
@@ -75,7 +69,6 @@ impl Display for MidjourneyApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
// Response body errors
Self::NoJobId => write!(f, "No job ID found in the response body."),
Self::NoUserId => write!(f, "No user ID found in the response body."),
Self::NoUserProps => write!(f, "No user properties found in the index HTML payload."),
Self::NoInitialAuthUser => write!(f, "No initialAuthUser in the index HTML payload."),
@@ -100,24 +93,11 @@ impl Display for MidjourneyApiError {
// I/O errors
Self::IoError(error) => write!(f, "IO error: {}", error),
// Other
// Self::OtherReqwestError(error) => write!(f, "Reqwest error: {}", error),
Self::Other(error) => write!(f, "Other error: {}", error),
}
}
}
/*impl From<reqwest::Error> for MidjourneyApiError {
fn from(error: reqwest::Error) -> Self {
if error.is_timeout() {
Self::Timeout(error.to_string())
} else if error.is_connect() {
Self::NetworkError(error.to_string())
} else {
Self::OtherReqwestError(error)
}
}
}*/
impl From<serde_json::Error> for MidjourneyApiError {
fn from(error: serde_json::Error) -> Self {
Self::DeserializationError(error)
@@ -9,7 +9,7 @@ pub struct GetMidjourneyUserIdRequest {
pub cookie_header: String,
}
pub async fn get_midjourney_user_id(req: GetMidjourneyUserIdRequest) -> Result<MidjourneyUserId, MidjourneyError> {
pub async fn get_midjourney_user_id_from_storage(req: GetMidjourneyUserIdRequest) -> Result<MidjourneyUserId, MidjourneyError> {
let items = storage_list(GetStorageListRequest {
hostname: req.hostname,
cookie_header: req.cookie_header,
@@ -28,7 +28,7 @@ pub async fn get_midjourney_user_id(req: GetMidjourneyUserIdRequest) -> Result<M
#[cfg(test)]
mod tests {
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::recipes::get_midjourney_user_id::{get_midjourney_user_id, GetMidjourneyUserIdRequest};
use crate::recipes::get_midjourney_user_id_from_storage::{get_midjourney_user_id_from_storage, GetMidjourneyUserIdRequest};
use errors::AnyhowResult;
use filesys::read_to_trimmed_string::read_to_trimmed_string;
@@ -37,7 +37,7 @@ mod tests {
async fn test() -> AnyhowResult<()> {
let cookie_header = read_to_trimmed_string("/Users/bt/secrets/midjourney/cookie.txt")?;
let result = get_midjourney_user_id(GetMidjourneyUserIdRequest {
let result = get_midjourney_user_id_from_storage(GetMidjourneyUserIdRequest {
cookie_header,
hostname: MidjourneyHostname::Standard,
}).await?;
@@ -1,3 +1,4 @@
mod get_midjourney_user_id;
mod get_midjourney_user_id_from_storage;
pub mod channel_id;
pub mod get_user_info;
pub mod get_user_info;
pub mod text_to_image;
@@ -0,0 +1,49 @@
use crate::client::midjourney_hostname::MidjourneyHostname;
use crate::endpoints::submit_job::{submit_job, SubmitJobRequest};
use crate::error::midjourney_error::MidjourneyError;
use crate::recipes::channel_id::ChannelId;
pub struct TextToImageRequest<'a> {
pub prompt: &'a str,
pub channel_id: &'a ChannelId,
pub hostname: MidjourneyHostname,
pub cookie_header: String,
}
#[derive(Debug, Clone)]
pub struct TextToImageResponse {
/// On success, the job ID is returned.
pub maybe_job_id: Option<String>,
/// On error, we have a list of error messages.
pub maybe_errors: Option<Vec<TextToImageError>>,
}
#[derive(Debug, Clone)]
pub struct TextToImageError {
pub error_type: Option<String>,
pub message: Option<String>,
}
/// Slightly more ergonomic text-to-image API.
/// As we add more `submit_job()` cases, we'll keep this simple.
pub async fn text_to_image(req: TextToImageRequest<'_>) -> Result<TextToImageResponse, MidjourneyError> {
let channel_id = req.channel_id.to_string();
let response = submit_job(SubmitJobRequest {
prompt: req.prompt,
channel_id: &channel_id,
hostname: req.hostname,
cookie_header: req.cookie_header,
}).await?;
Ok(TextToImageResponse {
maybe_job_id: response.maybe_job_id,
maybe_errors: response.maybe_errors.map(|errs| {
errs.into_iter().map(|e| TextToImageError {
error_type: e.error_type,
message: e.message,
}).collect()
}),
})
}
@@ -248,6 +248,7 @@ pub async fn dispatch_request(
}
Some(ImageModel::Midjourney) => {
return handle_midjourney(
app,
request,
app_env_configs,
mj_creds_manager,
@@ -11,7 +11,14 @@ pub enum InternalImageError {
NoModelSpecified,
NoProviderAvailable,
NeedsFalApiKey,
NeedsMidjourneyCredentials,
NeedsMidjourneyUserInfo,
NeedsMidjourneyUserId,
/// Generic - possibly this came with an error message that we sent as an event.
MidjourneyJobEnqueueFailed,
NeedsSoraCredentials,
NeedsStorytellerCredentials,
FalError(FalErrorPlus),
@@ -4,8 +4,10 @@ use crate::core::commands::enqueue::image_edit::enqueue_contextual_edit_image_co
use crate::core::commands::enqueue::image_edit::errors::InternalContextualEditImageError;
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::functional_events::canvas_background_removal_complete_event::CanvasBackgroundRemovalCompleteEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::events::warning_events::flash_user_input_error_event::FlashUserInputErrorEvent;
use crate::core::model::image_models::ImageModel;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
@@ -22,6 +24,9 @@ use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use midjourney_client::client::midjourney_hostname::MidjourneyHostname;
use midjourney_client::endpoints::submit_job::{submit_job, SubmitJobRequest};
use midjourney_client::error::midjourney_api_error::MidjourneyApiError;
use midjourney_client::recipes::channel_id::ChannelId;
use midjourney_client::recipes::text_to_image::{text_to_image, TextToImageError, TextToImageRequest};
use openai_sora_client::recipes::maybe_upgrade_or_renew_session::maybe_upgrade_or_renew_session;
use openai_sora_client::recipes::simple_image_gen_with_session_auto_renew::{simple_image_gen_with_session_auto_renew, SimpleImageGenAutoRenewRequest};
use openai_sora_client::requests::image_gen::common::{ImageSize, NumImages};
@@ -29,8 +34,10 @@ use std::time::Duration;
use storyteller_client::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use storyteller_client::generate::image::generate_gpt_image_1_text_to_image::generate_gpt_image_1_text_to_image;
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
pub async fn handle_midjourney(
app: &AppHandle,
request: EnqueueTextToImageRequest,
app_env_configs: &AppEnvConfigs,
mj_creds_manager: &MidjourneyCredentialManager,
@@ -47,6 +54,27 @@ pub async fn handle_midjourney(
},
};
// TODO: We can request population of the user info if absent or expired.
let user_info = match mj_creds_manager.maybe_copy_user_info() {
Ok(Some(user_info)) => user_info,
Ok(None) => {
return Err(InternalImageError::NeedsMidjourneyUserInfo);
}
Err(err) => {
error!("Error reading Midjourney user info: {:?}", err);
return Err(InternalImageError::NeedsMidjourneyUserInfo);
},
};
let channel_id = match user_info.user_id {
Some(user_id) => ChannelId::UserId(user_id),
None => {
error!("Midjourney user info does not contain a user ID.");
return Err(InternalImageError::NeedsMidjourneyUserId);
}
};
info!("Calling midjourney ...");
let cookie_header = creds.to_cookie_string();
@@ -55,25 +83,30 @@ pub async fn handle_midjourney(
.as_deref()
.unwrap_or("");
let result = submit_job(SubmitJobRequest {
let result = text_to_image(TextToImageRequest {
prompt,
channel_id: "singleplayer_f8a57ac3-e416-4dd4-9be8-2c4223691b01", // TODO: DO NOT COMMIT
channel_id: &channel_id,
hostname: MidjourneyHostname::Standard,
cookie_header,
}).await;
let job_id = match result {
Ok(result) => {
// TODO(bt,2025-07-05): Enqueue job token?
info!("Successfully enqueued MidJourney. Job token: {}",result.job_id);
result.job_id
}
let result = match result {
Ok(result) => result,
Err(err) => {
error!("Failed to use MidJourney: {:?}", err);
return Err(InternalImageError::MidjourneyError(err));
}
};
let job_id = match result.maybe_job_id {
Some(job_id) => job_id,
None => {
error!("Failed to enqueue MidJourney: No job ID returned.");
return handle_midjourney_errors(app, result.maybe_errors);
}
};
info!("Successfully enqueued MidJourney. Job token: {}", job_id);
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Midjourney,
@@ -82,3 +115,28 @@ pub async fn handle_midjourney(
task_type: TaskType::ImageGeneration,
})
}
fn handle_midjourney_errors(
app: &AppHandle,
maybe_errors: Option<Vec<TextToImageError>>
) -> Result<TaskEnqueueSuccess, InternalImageError> {
if let Some(errors) = maybe_errors {
if !errors.is_empty() {
let messages: Vec<String> = errors.iter()
.map(|e| format!("{:?}", e))
.collect();
let combined_message = messages.join("; ");
let event = FlashUserInputErrorEvent {
message: format!("Midjourney Error: {}", combined_message),
};
if let Err(err) = event.send(&app) {
error!("Failed to send FlashUserInputErrorEvent: {:?}", err); // Fail open
}
}
}
Err(InternalImageError::MidjourneyJobEnqueueFailed)
}
@@ -1,5 +1,6 @@
pub mod basic_sendable_event_trait;
pub mod functional_events;
pub mod generation_events;
pub mod sendable_event_error;
pub mod sendable_event_trait;
pub mod functional_events;
pub mod warning_events;
@@ -0,0 +1,14 @@
use crate::core::events::basic_sendable_event_trait::{BasicEventStatus, BasicSendableEvent};
use enums::tauri::ux::tauri_event_name::TauriEventName;
use serde_derive::Serialize;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct FlashUserInputErrorEvent {
pub message: String,
}
impl BasicSendableEvent for FlashUserInputErrorEvent {
const FRONTEND_EVENT_NAME: TauriEventName = TauriEventName::FlashUserInputErrorEvent;
const EVENT_STATUS: BasicEventStatus = BasicEventStatus::Failure;
}
@@ -0,0 +1 @@
pub mod flash_user_input_error_event;
@@ -47,6 +47,11 @@ pub enum TauriEventName {
/// Refresh account states
#[serde(rename = "refresh_account_state_event")]
RefreshAccountStateEvent,
/// Warning event:
/// Flash a user input error message
#[serde(rename = "flash_user_input_error_event")]
FlashUserInputErrorEvent,
}
impl_enum_display_and_debug_using_to_str!(TauriEventName);
@@ -64,6 +69,7 @@ impl TauriEventName {
Self::GenerationFailedEvent => "generation-failed-event",
Self::CanvasBgRemovedEvent => "canvas_bg_removed_event",
Self::RefreshAccountStateEvent => "refresh_account_state_event",
Self::FlashUserInputErrorEvent => "flash_user_input_error_event",
}
}
@@ -75,6 +81,7 @@ impl TauriEventName {
"generation-failed-event" => Ok(Self::GenerationFailedEvent),
"canvas_bg_removed_event" => Ok(Self::CanvasBgRemovedEvent),
"refresh_account_state_event" => Ok(Self::RefreshAccountStateEvent),
"flash_user_input_error_event" => Ok(Self::FlashUserInputErrorEvent),
_ => Err(format!("invalid tauri_event_name: {:?}", job_status)),
}
}
@@ -89,6 +96,7 @@ impl TauriEventName {
Self::GenerationFailedEvent,
Self::CanvasBgRemovedEvent,
Self::RefreshAccountStateEvent,
Self::FlashUserInputErrorEvent,
])
}
}
@@ -109,6 +117,7 @@ mod tests {
assert_serialization(TauriEventName::GenerationFailedEvent, "generation-failed-event");
assert_serialization(TauriEventName::CanvasBgRemovedEvent, "canvas_bg_removed_event");
assert_serialization(TauriEventName::RefreshAccountStateEvent, "refresh_account_state_event");
assert_serialization(TauriEventName::FlashUserInputErrorEvent, "flash_user_input_error_event");
}
#[test]
@@ -119,6 +128,7 @@ mod tests {
assert_eq!(TauriEventName::GenerationFailedEvent.to_str(), "generation-failed-event");
assert_eq!(TauriEventName::CanvasBgRemovedEvent.to_str(), "canvas_bg_removed_event");
assert_eq!(TauriEventName::RefreshAccountStateEvent.to_str(), "refresh_account_state_event");
assert_eq!(TauriEventName::FlashUserInputErrorEvent.to_str(), "flash_user_input_error_event");
}
#[test]
@@ -129,18 +139,20 @@ mod tests {
assert_eq!(TauriEventName::from_str("generation-failed-event").unwrap(), TauriEventName::GenerationFailedEvent);
assert_eq!(TauriEventName::from_str("canvas_bg_removed_event").unwrap(), TauriEventName::CanvasBgRemovedEvent);
assert_eq!(TauriEventName::from_str("refresh_account_state_event").unwrap(), TauriEventName::RefreshAccountStateEvent);
assert_eq!(TauriEventName::from_str("flash_user_input_error_event").unwrap(), TauriEventName::FlashUserInputErrorEvent);
}
#[test]
fn all_variants() {
let mut variants = TauriEventName::all_variants();
assert_eq!(variants.len(), 6);
assert_eq!(variants.len(), 7);
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueSuccessEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationEnqueueFailureEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationCompleteEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::GenerationFailedEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::CanvasBgRemovedEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::RefreshAccountStateEvent));
assert_eq!(variants.pop_first(), Some(TauriEventName::FlashUserInputErrorEvent));
assert_eq!(variants.pop_first(), None);
}
}
@@ -5,7 +5,7 @@ import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
import { useQueueHandler } from "./hooks/useQueueHandler";
import { ErrorDialog } from "~/components";
import { GenerateModals } from "~/pages/PageEnigma/comps/GenerateModals/GenerateModals";
import { Toaster } from "@storyteller/ui-toaster";
import { toast, Toaster } from "@storyteller/ui-toaster";
import { EditorLoadingBar } from "./comps/EditorLoadingBar";
import { useSignals } from "@preact/signals-react/runtime";
import { useEffect, useState } from "react";
@@ -34,6 +34,7 @@ import {
actionReminderProps,
ActionReminderModal,
} from "@storyteller/ui-action-reminder-modal";
import { useFlashUserInputErrorEvent } from "@storyteller/tauri-events";
export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
useSignals();
@@ -87,6 +88,11 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
useGenerationCompleteEvent();
useGenerationFailedEvent();
useFlashUserInputErrorEvent(async (event) => {
console.log("Flash user input error event received:", event);
toast.error(event.message);
});
useEffect(() => {
console.log("installing event listeners");
InstallSounds();
+3
View File
@@ -136,6 +136,9 @@
{
"path": "../../libs/tauri-utils"
},
{
"path": "../../libs/tauri-events"
},
{
"path": "../../libs/api"
}
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './lib/tauri-events';
export * from './lib/common/BasicEventWrapper';
export * from './lib/events/RefreshAccountStateEvent';
export * from './lib/events/FlashUserInputErrorEvent';
@@ -0,0 +1,34 @@
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { BasicEventWrapper } from '../common/BasicEventWrapper';
import { useEffect } from 'react';
const EVENT_NAME : string = 'flash_user_input_error_event';
export interface FlashUserInputErrorEvent {
message: string,
}
export const useFlashUserInputErrorEvent = (asyncCallback: (event: FlashUserInputErrorEvent) => Promise<void>) => {
useEffect(() => {
let isUnmounted = false;
let unlisten: Promise<UnlistenFn>;
const setup = async () => {
unlisten = listen<BasicEventWrapper<FlashUserInputErrorEvent>>(EVENT_NAME, async (wrappedEvent) => {
await asyncCallback(wrappedEvent.payload.data);
});
if (isUnmounted) {
unlisten.then(f => f()); // Unsubscribe if unmounted early.
}
};
setup();
return () => {
isUnmounted = true;
unlisten.then(f => f());
};
}, []);
}