From 4bb32e33b09f18b4ff62a207b8b5d5ac4cd4a7bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:29:31 +0000 Subject: [PATCH 1/3] Initial plan From c8cb76d2517b6d912fcc20f0b78cbd02c9ec1db7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:39:24 +0000 Subject: [PATCH 2/3] Fix images not generating: change IMAGE_MODEL default from gpt-image-1-mini to gpt-image-1 Agent-Logs-Url: https://github.com/CyberSecDef/NovelForge/sessions/0f18f183-a633-420c-a80f-24ad8e7364a1 Co-authored-by: CyberSecDef <17597068+CyberSecDef@users.noreply.github.com> --- novelforge/config.py | 2 +- tests/test_image_model_config.py | 96 ++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/test_image_model_config.py diff --git a/novelforge/config.py b/novelforge/config.py index bd6e072..2b2c279 100644 --- a/novelforge/config.py +++ b/novelforge/config.py @@ -196,7 +196,7 @@ def _parse_llm_providers() -> list[ProviderConfig]: IMAGE_API_KEY = os.environ.get("IMAGE_API_KEY", "") # Image generation model name to request -IMAGE_MODEL = os.environ.get("IMAGE_MODEL", "gpt-image-1-mini") +IMAGE_MODEL = os.environ.get("IMAGE_MODEL", "gpt-image-1") # Image generation size IMAGE_SIZE = os.environ.get("IMAGE_SIZE", "1024x1024") diff --git a/tests/test_image_model_config.py b/tests/test_image_model_config.py new file mode 100644 index 0000000..7893ec0 --- /dev/null +++ b/tests/test_image_model_config.py @@ -0,0 +1,96 @@ +"""Tests that IMAGE_MODEL default is the correct 'gpt-image-1' model name. + +The model 'gpt-image-1-mini' does not exist in the OpenAI API; using it +causes every image generation request to fail with a 4xx error and the +UI shows "No illustrations were generated." +""" + +import base64 +import pathlib +from unittest.mock import MagicMock, patch + + +def _make_api_response(payload: dict) -> MagicMock: + """Build a mock requests.Response for the image generation POST call.""" + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = payload + resp.raise_for_status.return_value = None + return resp + + +class TestImageModelDefault: + """Verify that the default IMAGE_MODEL is 'gpt-image-1' (not 'gpt-image-1-mini').""" + + def test_default_image_model_in_source(self): + """config.py source must use 'gpt-image-1' as the fallback, not 'gpt-image-1-mini'.""" + config_path = ( + pathlib.Path(__file__).resolve().parent.parent + / "novelforge" + / "config.py" + ) + source = config_path.read_text() + assert '"gpt-image-1-mini"' not in source, ( + "Found invalid model 'gpt-image-1-mini' in novelforge/config.py. " + "This model does not exist in the OpenAI API and causes all image " + "generation requests to fail. Use 'gpt-image-1' instead." + ) + assert '"gpt-image-1"' in source, ( + "novelforge/config.py should use 'gpt-image-1' as the IMAGE_MODEL default." + ) + + def test_model_name_sent_in_api_request(self, tmp_path, monkeypatch): + """call_image_api must send 'gpt-image-1' as the model in the POST payload.""" + import novelforge.config as config + import novelforge.llm.image as image_mod + + monkeypatch.setattr(config, "IMAGE_API_KEY", "test-key") + monkeypatch.setattr(config, "IMAGE_API_URL", "https://api.openai.com/v1/images/generations") + monkeypatch.setattr(config, "IMAGE_MODEL", "gpt-image-1") + monkeypatch.setattr(config, "IMAGE_SIZE", "1024x1024") + monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) + + b64_data = base64.b64encode(b"fake_png_bytes").decode() + api_resp = _make_api_response({"data": [{"b64_json": b64_data}]}) + + post_calls = [] + + def fake_post(url, headers=None, json=None, timeout=None, **kwargs): + post_calls.append({"url": url, "json": json}) + return api_resp + + with patch.object(image_mod.requests, "post", side_effect=fake_post): + result = image_mod.call_image_api("a scene", filename_prefix="ch1") + + assert result is not None, "call_image_api should succeed with valid model" + assert len(post_calls) == 1 + assert post_calls[0]["json"]["model"] == "gpt-image-1", ( + f"Expected model='gpt-image-1' in POST body, got {post_calls[0]['json']['model']!r}" + ) + + def test_invalid_model_name_gpt_image_1_mini_returns_none(self, tmp_path, monkeypatch): + """Sending 'gpt-image-1-mini' to the API triggers a 400 error → returns None.""" + import requests as requests_lib + + import novelforge.config as config + import novelforge.llm.image as image_mod + + monkeypatch.setattr(config, "IMAGE_API_KEY", "test-key") + monkeypatch.setattr(config, "IMAGE_API_URL", "https://api.openai.com/v1/images/generations") + monkeypatch.setattr(config, "IMAGE_MODEL", "gpt-image-1-mini") + monkeypatch.setattr(config, "IMAGE_SIZE", "1024x1024") + monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) + + # Simulate the OpenAI API response for an unknown model: 400 Bad Request. + error_resp = MagicMock() + error_resp.status_code = 400 + error_resp.raise_for_status.side_effect = requests_lib.exceptions.HTTPError( + "400 Client Error: Bad Request", response=error_resp + ) + + with patch.object(image_mod.requests, "post", return_value=error_resp): + result = image_mod.call_image_api("a scene", filename_prefix="ch1") + + assert result is None, ( + "call_image_api should return None when the API rejects an invalid model name" + ) From 39f8bdd53362cb27c47be7bb76156158874fcc44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:52:42 +0000 Subject: [PATCH 3/3] Fix illustration UI: poll background job token to display generated images Agent-Logs-Url: https://github.com/CyberSecDef/NovelForge/sessions/0004c800-39d0-4bb9-a3b4-cb4f5b38327f Co-authored-by: CyberSecDef <17597068+CyberSecDef@users.noreply.github.com> --- novelforge/config.py | 2 +- static/js/script.js | 82 +++++++++++++++++++++++++-- templates/index.html | 1 + tests/test_image_model_config.py | 96 -------------------------------- 4 files changed, 80 insertions(+), 101 deletions(-) delete mode 100644 tests/test_image_model_config.py diff --git a/novelforge/config.py b/novelforge/config.py index 2b2c279..bd6e072 100644 --- a/novelforge/config.py +++ b/novelforge/config.py @@ -196,7 +196,7 @@ def _parse_llm_providers() -> list[ProviderConfig]: IMAGE_API_KEY = os.environ.get("IMAGE_API_KEY", "") # Image generation model name to request -IMAGE_MODEL = os.environ.get("IMAGE_MODEL", "gpt-image-1") +IMAGE_MODEL = os.environ.get("IMAGE_MODEL", "gpt-image-1-mini") # Image generation size IMAGE_SIZE = os.environ.get("IMAGE_SIZE", "1024x1024") diff --git a/static/js/script.js b/static/js/script.js index 5394c7b..b30b648 100644 --- a/static/js/script.js +++ b/static/js/script.js @@ -1359,10 +1359,76 @@ $(function () { $gallery.removeClass("d-none"); } + // Poll the illustration job token until it reaches a terminal state, + // then fetch the full payload and render the images. + function _pollIllustrationJob(illustToken, _errorRetries) { + var retries = _errorRetries || 0; + $.ajax({ + url: "/progress/" + illustToken, + method: "GET", + success: function (data) { + if (data.status === "done") { + // Job finished – fetch the full payload to get the illustrations array. + $.ajax({ + url: "/progress/" + illustToken + "/full", + method: "GET", + success: function (full) { + renderIllustrations(full.illustrations || []); + }, + error: function () { + renderIllustrations([]); + }, + complete: function () { + $("#illustrations-spinner").addClass("d-none"); + $("#illustrations-spinner-label").addClass("d-none").text(""); + _enableExportButtons(); + }, + }); + } else if (data.status === "error") { + showAlert( + data.error || + "Illustration generation failed. The AI service may be rate-limited — wait a few minutes and try again." + ); + $("#illustrations-spinner").addClass("d-none"); + $("#illustrations-spinner-label").addClass("d-none").text(""); + _enableExportButtons(); + } else { + // Still running – update the spinner label and keep polling. + var stepText = data.step || "Generating\u2026"; + var current = data.current || 0; + var total = data.total || 0; + var label = total > 0 + ? stepText + " (" + current + "/" + total + ")" + : stepText; + $("#illustrations-spinner-label").text(label); + setTimeout(function () { + _pollIllustrationJob(illustToken, 0); + }, 3000); + } + }, + error: function () { + // Retry on transient network errors, up to 10 attempts. + if (retries < 10) { + setTimeout(function () { + _pollIllustrationJob(illustToken, retries + 1); + }, 5000); + } else { + showAlert( + "Lost connection to the server while waiting for illustrations. Please check your connection and try again." + ); + $("#illustrations-spinner").addClass("d-none"); + $("#illustrations-spinner-label").addClass("d-none").text(""); + _enableExportButtons(); + } + }, + }); + } + $("#btn-generate-illustrations").on("click", function () { clearAlerts(); var $btn = $(this); $("#illustrations-spinner").removeClass("d-none"); + $("#illustrations-spinner-label").removeClass("d-none").text("Starting\u2026"); _disableExportButtons(); $.ajax({ @@ -1370,18 +1436,26 @@ $(function () { method: "POST", contentType: "application/json", data: JSON.stringify({ token: _progressToken }), - timeout: 600000, // 10 min timeout for image generations success: function (resp) { - renderIllustrations(resp.illustrations || []); + var illustToken = resp.illustration_token; + if (illustToken) { + // Backend accepted the job; poll until done. + _pollIllustrationJob(illustToken, 0); + } else { + // Unexpected: no token in response. + renderIllustrations(resp.illustrations || []); + $("#illustrations-spinner").addClass("d-none"); + $("#illustrations-spinner-label").addClass("d-none").text(""); + _enableExportButtons(); + } }, error: function (xhr) { var msg = (xhr.responseJSON && xhr.responseJSON.error) || "Illustration generation failed. The AI service may be rate-limited — wait a few minutes and try again."; showAlert(msg); - }, - complete: function () { $("#illustrations-spinner").addClass("d-none"); + $("#illustrations-spinner-label").addClass("d-none").text(""); _enableExportButtons(); }, }); diff --git a/templates/index.html b/templates/index.html index 5b09315..468eb24 100644 --- a/templates/index.html +++ b/templates/index.html @@ -439,6 +439,7 @@
Revise Indi
Illustrations
Generates 5–10 images from key scenes (requires IMAGE_API_KEY) diff --git a/tests/test_image_model_config.py b/tests/test_image_model_config.py deleted file mode 100644 index 7893ec0..0000000 --- a/tests/test_image_model_config.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Tests that IMAGE_MODEL default is the correct 'gpt-image-1' model name. - -The model 'gpt-image-1-mini' does not exist in the OpenAI API; using it -causes every image generation request to fail with a 4xx error and the -UI shows "No illustrations were generated." -""" - -import base64 -import pathlib -from unittest.mock import MagicMock, patch - - -def _make_api_response(payload: dict) -> MagicMock: - """Build a mock requests.Response for the image generation POST call.""" - resp = MagicMock() - resp.status_code = 200 - resp.json.return_value = payload - resp.raise_for_status.return_value = None - return resp - - -class TestImageModelDefault: - """Verify that the default IMAGE_MODEL is 'gpt-image-1' (not 'gpt-image-1-mini').""" - - def test_default_image_model_in_source(self): - """config.py source must use 'gpt-image-1' as the fallback, not 'gpt-image-1-mini'.""" - config_path = ( - pathlib.Path(__file__).resolve().parent.parent - / "novelforge" - / "config.py" - ) - source = config_path.read_text() - assert '"gpt-image-1-mini"' not in source, ( - "Found invalid model 'gpt-image-1-mini' in novelforge/config.py. " - "This model does not exist in the OpenAI API and causes all image " - "generation requests to fail. Use 'gpt-image-1' instead." - ) - assert '"gpt-image-1"' in source, ( - "novelforge/config.py should use 'gpt-image-1' as the IMAGE_MODEL default." - ) - - def test_model_name_sent_in_api_request(self, tmp_path, monkeypatch): - """call_image_api must send 'gpt-image-1' as the model in the POST payload.""" - import novelforge.config as config - import novelforge.llm.image as image_mod - - monkeypatch.setattr(config, "IMAGE_API_KEY", "test-key") - monkeypatch.setattr(config, "IMAGE_API_URL", "https://api.openai.com/v1/images/generations") - monkeypatch.setattr(config, "IMAGE_MODEL", "gpt-image-1") - monkeypatch.setattr(config, "IMAGE_SIZE", "1024x1024") - monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - - b64_data = base64.b64encode(b"fake_png_bytes").decode() - api_resp = _make_api_response({"data": [{"b64_json": b64_data}]}) - - post_calls = [] - - def fake_post(url, headers=None, json=None, timeout=None, **kwargs): - post_calls.append({"url": url, "json": json}) - return api_resp - - with patch.object(image_mod.requests, "post", side_effect=fake_post): - result = image_mod.call_image_api("a scene", filename_prefix="ch1") - - assert result is not None, "call_image_api should succeed with valid model" - assert len(post_calls) == 1 - assert post_calls[0]["json"]["model"] == "gpt-image-1", ( - f"Expected model='gpt-image-1' in POST body, got {post_calls[0]['json']['model']!r}" - ) - - def test_invalid_model_name_gpt_image_1_mini_returns_none(self, tmp_path, monkeypatch): - """Sending 'gpt-image-1-mini' to the API triggers a 400 error → returns None.""" - import requests as requests_lib - - import novelforge.config as config - import novelforge.llm.image as image_mod - - monkeypatch.setattr(config, "IMAGE_API_KEY", "test-key") - monkeypatch.setattr(config, "IMAGE_API_URL", "https://api.openai.com/v1/images/generations") - monkeypatch.setattr(config, "IMAGE_MODEL", "gpt-image-1-mini") - monkeypatch.setattr(config, "IMAGE_SIZE", "1024x1024") - monkeypatch.setattr(config, "EXPORT_DIR", str(tmp_path)) - - # Simulate the OpenAI API response for an unknown model: 400 Bad Request. - error_resp = MagicMock() - error_resp.status_code = 400 - error_resp.raise_for_status.side_effect = requests_lib.exceptions.HTTPError( - "400 Client Error: Bad Request", response=error_resp - ) - - with patch.object(image_mod.requests, "post", return_value=error_resp): - result = image_mod.call_image_api("a scene", filename_prefix="ch1") - - assert result is None, ( - "call_image_api should return None when the API rejects an invalid model name" - )