@@ -116,7 +116,7 @@ def _invalidate_token_cache() -> None:
116116 _token_username = None
117117
118118
119- async def _post_with_auth_fallback (
119+ async def _request_with_auth_fallback (
120120 base_url : str ,
121121 username : str | None ,
122122 password : str | None ,
@@ -156,11 +156,12 @@ async def _post_with_auth_fallback(
156156
157157
158158class InvokeAISettings (BaseModel ):
159- """Mirrors the three config fields we expose via the settings panel."""
159+ """Mirrors the config fields we expose via the settings panel."""
160160
161161 url : str | None = None
162162 username : str | None = None
163163 password : str | None = None
164+ board_id : str | None = None
164165
165166
166167class RecallRequest (BaseModel ):
@@ -195,24 +196,28 @@ async def get_invokeai_config() -> dict:
195196 "url" : settings ["url" ] or "" ,
196197 "username" : settings ["username" ] or "" ,
197198 "has_password" : bool (settings ["password" ]),
199+ "board_id" : settings ["board_id" ] or "" ,
198200 }
199201
200202
201203@invoke_router .post ("/config" )
202204async def set_invokeai_config (settings : InvokeAISettings ) -> dict :
203205 """Persist the InvokeAI connection settings.
204206
205- A password of ``None`` leaves the existing stored password untouched so
206- the settings panel can re-submit without clobbering what was saved.
207+ A password or board_id of ``None`` leaves the existing stored value
208+ untouched so the settings panel can PATCH individual fields without
209+ clobbering what was saved. Send an empty string to explicitly clear.
207210 """
208211 _invalidate_token_cache ()
209212 existing = config_manager .get_invokeai_settings ()
210213 password = settings .password if settings .password is not None else existing ["password" ]
214+ board_id = settings .board_id if settings .board_id is not None else existing ["board_id" ]
211215 try :
212216 config_manager .set_invokeai_settings (
213217 url = settings .url ,
214218 username = settings .username ,
215219 password = password ,
220+ board_id = board_id ,
216221 )
217222 except Exception as exc :
218223 logger .exception ("Failed to persist InvokeAI settings" )
@@ -222,6 +227,115 @@ async def set_invokeai_config(settings: InvokeAISettings) -> dict:
222227 return {"success" : True }
223228
224229
230+ @invoke_router .get ("/status" )
231+ async def invokeai_status () -> dict :
232+ """Report whether the configured URL is reachable and looks like InvokeAI.
233+
234+ Probes the unauthenticated ``/api/v1/app/version`` endpoint. A successful
235+ JSON response with a ``version`` field is the signal the settings UI uses
236+ to reveal the username/password/board rows. Returns
237+ ``{"reachable": False, "detail": ...}`` for any network or HTTP failure
238+ rather than raising, so the frontend can render a neutral hint instead of
239+ an error banner while the user is still typing.
240+ """
241+ settings = config_manager .get_invokeai_settings ()
242+ base_url = settings ["url" ]
243+ if not base_url :
244+ return {"reachable" : False , "detail" : "No InvokeAI URL configured" }
245+
246+ version_url = f"{ base_url .rstrip ('/' )} /api/v1/app/version"
247+ try :
248+ async with httpx .AsyncClient (timeout = _HTTP_TIMEOUT ) as client :
249+ resp = await client .get (version_url )
250+ except httpx .RequestError as exc :
251+ return {"reachable" : False , "detail" : f"Could not reach backend: { exc } " }
252+
253+ if resp .status_code != 200 :
254+ return {
255+ "reachable" : False ,
256+ "detail" : f"Backend returned HTTP { resp .status_code } " ,
257+ }
258+ try :
259+ payload = resp .json ()
260+ except ValueError :
261+ return {"reachable" : False , "detail" : "Backend did not return JSON" }
262+ version = payload .get ("version" )
263+ if not version :
264+ # A non-InvokeAI server happening to have /api/v1/app/version would
265+ # almost certainly not return a version field.
266+ return {"reachable" : False , "detail" : "Response missing version field" }
267+ return {"reachable" : True , "version" : version }
268+
269+
270+ @invoke_router .get ("/boards" )
271+ async def invokeai_boards () -> list [dict ]:
272+ """Return the list of boards from the configured InvokeAI backend.
273+
274+ Uses the same auth-fallback pattern as the recall and upload endpoints.
275+ Returns a flat ``[{"board_id": ..., "board_name": ...}]`` list — the
276+ frontend populates its dropdown directly from this. Any failure
277+ (unreachable, auth, 5xx) raises 502; the frontend then renders a
278+ disabled "Uncategorized" option.
279+ """
280+ settings = config_manager .get_invokeai_settings ()
281+ base_url = settings ["url" ]
282+ if not base_url :
283+ raise HTTPException (
284+ status_code = 400 , detail = "InvokeAI backend URL is not configured."
285+ )
286+
287+ boards_url = f"{ base_url .rstrip ('/' )} /api/v1/boards/"
288+ username = settings ["username" ]
289+ password = settings ["password" ]
290+
291+ try :
292+ async with httpx .AsyncClient (timeout = _HTTP_TIMEOUT ) as client :
293+
294+ async def _do (headers : dict [str , str ]) -> httpx .Response :
295+ return await client .get (
296+ boards_url , params = {"all" : "true" }, headers = headers
297+ )
298+
299+ response = await _request_with_auth_fallback (
300+ base_url , username , password , _do
301+ )
302+ except httpx .RequestError as exc :
303+ logger .warning ("InvokeAI boards request failed: %s" , exc )
304+ raise HTTPException (
305+ status_code = 502 ,
306+ detail = f"Could not reach InvokeAI backend at { base_url } : { exc } " ,
307+ ) from exc
308+
309+ if response .status_code >= 400 :
310+ raise HTTPException (
311+ status_code = 502 ,
312+ detail = (
313+ f"InvokeAI backend returned { response .status_code } : "
314+ f"{ response .text [:200 ]} "
315+ ),
316+ )
317+
318+ try :
319+ raw = response .json ()
320+ except ValueError as exc :
321+ raise HTTPException (
322+ status_code = 502 , detail = "Boards endpoint did not return JSON"
323+ ) from exc
324+
325+ # ``?all=true`` returns a flat list; without it InvokeAI returns
326+ # ``{"items": [...], "offset": ..., "total": ...}``. Handle both shapes
327+ # so an accidentally-paginated response doesn't blank out the dropdown.
328+ items = raw if isinstance (raw , list ) else raw .get ("items" , [])
329+ return [
330+ {
331+ "board_id" : item .get ("board_id" ),
332+ "board_name" : item .get ("board_name" ) or "(unnamed board)" ,
333+ }
334+ for item in items
335+ if isinstance (item , dict ) and item .get ("board_id" )
336+ ]
337+
338+
225339def _load_raw_metadata (album_key : str , index : int ) -> dict :
226340 embeddings = get_embeddings_for_album (album_key )
227341 if not embeddings :
@@ -300,7 +414,7 @@ async def _do(headers: dict[str, str]) -> httpx.Response:
300414 url , json = payload , params = {"strict" : "true" }, headers = headers
301415 )
302416
303- response = await _post_with_auth_fallback (
417+ response = await _request_with_auth_fallback (
304418 base_url , username , password , _do
305419 )
306420 except httpx .RequestError as exc :
@@ -339,25 +453,51 @@ async def _upload_image_to_invokeai(
339453 image_path : Path ,
340454 username : str | None ,
341455 password : str | None ,
342- ) -> str :
343- """Upload ``image_path`` to InvokeAI and return the assigned ``image_name``.
456+ board_id : str | None = None ,
457+ ) -> tuple [str , str | None ]:
458+ """Upload ``image_path`` to InvokeAI and return ``(image_name, warning)``.
459+
460+ If ``board_id`` is provided, the upload targets that board. If that
461+ attempt fails (board deleted, renamed, permission issue, etc.) the
462+ upload is retried without the board so the file lands in Uncategorized
463+ — that's the documented fallback behaviour. When the fallback kicks in
464+ a human-readable warning is returned alongside the image name so the
465+ caller can surface it to the UI.
344466
345467 Auth transitions (anonymous ↔ token) are handled transparently by
346- ``_post_with_auth_fallback ``; the multipart stream is re-opened on each
347- retry since the previous one will have been consumed.
468+ ``_request_with_auth_fallback ``; the multipart stream is re-opened on
469+ each retry since the previous one will have been consumed.
348470 """
349471 upload_url = f"{ base_url .rstrip ('/' )} /api/v1/images/upload"
350472 mime_type = mimetypes .guess_type (image_path .name )[0 ] or "image/png"
351- params = {"image_category" : "user" , "is_intermediate" : "false" }
473+ base_params = {"image_category" : "user" , "is_intermediate" : "false" }
352474
353- async def _do (headers : dict [str , str ]) -> httpx .Response :
354- with image_path .open ("rb" ) as fh :
355- files = {"file" : (image_path .name , fh , mime_type )}
356- return await client .post (
357- upload_url , files = files , params = params , headers = headers
358- )
475+ async def _attempt (params : dict [str , str ]) -> httpx .Response :
476+ async def _do (headers : dict [str , str ]) -> httpx .Response :
477+ with image_path .open ("rb" ) as fh :
478+ files = {"file" : (image_path .name , fh , mime_type )}
479+ return await client .post (
480+ upload_url , files = files , params = params , headers = headers
481+ )
359482
360- upload_resp = await _post_with_auth_fallback (base_url , username , password , _do )
483+ return await _request_with_auth_fallback (base_url , username , password , _do )
484+
485+ warning : str | None = None
486+ if board_id :
487+ upload_resp = await _attempt ({** base_params , "board_id" : board_id })
488+ if upload_resp .status_code >= 400 :
489+ logger .warning (
490+ "InvokeAI upload to board %s failed (%s); falling back to Uncategorized" ,
491+ board_id ,
492+ upload_resp .status_code ,
493+ )
494+ warning = (
495+ f"Upload to the selected board failed "
496+ f"(HTTP { upload_resp .status_code } ); image was placed in Uncategorized."
497+ )
498+ upload_resp = await _attempt (base_params )
499+ else :
500+ upload_resp = await _attempt (base_params )
361501
362502 if upload_resp .status_code >= 400 :
363503 raise HTTPException (
@@ -382,7 +522,7 @@ async def _do(headers: dict[str, str]) -> httpx.Response:
382522 status_code = 502 ,
383523 detail = "InvokeAI image upload response did not include image_name" ,
384524 )
385- return image_name
525+ return image_name , warning
386526
387527
388528@invoke_router .post ("/use_ref_image" )
@@ -413,13 +553,14 @@ async def use_ref_image(request: UseRefImageRequest) -> dict:
413553
414554 username = settings ["username" ]
415555 password = settings ["password" ]
556+ board_id = settings ["board_id" ]
416557
417558 recall_url = f"{ base_url .rstrip ('/' )} /api/v1/recall/{ request .queue_id } "
418559
419560 try :
420561 async with httpx .AsyncClient (timeout = _HTTP_TIMEOUT ) as client :
421- image_name = await _upload_image_to_invokeai (
422- client , base_url , image_path , username , password
562+ image_name , board_warning = await _upload_image_to_invokeai (
563+ client , base_url , image_path , username , password , board_id = board_id
423564 )
424565
425566 # Deliberately omit ``strict=true`` so that the recall only
@@ -431,7 +572,7 @@ async def use_ref_image(request: UseRefImageRequest) -> dict:
431572 async def _do_recall (headers : dict [str , str ]) -> httpx .Response :
432573 return await client .post (recall_url , json = payload , headers = headers )
433574
434- response = await _post_with_auth_fallback (
575+ response = await _request_with_auth_fallback (
435576 base_url , username , password , _do_recall
436577 )
437578 except httpx .RequestError as exc :
@@ -460,9 +601,12 @@ async def _do_recall(headers: dict[str, str]) -> httpx.Response:
460601 except ValueError :
461602 remote = {"raw" : response .text }
462603
463- return {
604+ result = {
464605 "success" : True ,
465606 "sent" : payload ,
466607 "uploaded_image_name" : image_name ,
467608 "response" : remote ,
468609 }
610+ if board_warning :
611+ result ["warning" ] = board_warning
612+ return result
0 commit comments