Skip to content

Commit 422c864

Browse files
lsteinclaude
andauthored
feat(invoke): add image-board selector for reference uploads (#222)
Reveals the InvokeAI username/password and a new "Upload to Image Board" dropdown only after /invokeai/status confirms the configured URL points to a working backend. Reference-image uploads target the selected board, falling back to Uncategorized when the board can't be fetched at config time or rejects the upload at runtime. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ff216bb commit 422c864

6 files changed

Lines changed: 875 additions & 57 deletions

File tree

photomap/backend/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ class Config(BaseModel):
9393
default=None,
9494
description="Password for authenticating against InvokeAI (future use)",
9595
)
96+
invokeai_board_id: str | None = Field(
97+
default=None,
98+
description=(
99+
"ID of the InvokeAI board that reference-image uploads should be "
100+
"placed in. None means Uncategorized."
101+
),
102+
)
96103

97104
@field_validator("config_version")
98105
@classmethod
@@ -123,6 +130,7 @@ def to_dict(self) -> dict[str, Any]:
123130
"invokeai_url": self.invokeai_url,
124131
"invokeai_username": self.invokeai_username,
125132
"invokeai_password": self.invokeai_password,
133+
"invokeai_board_id": self.invokeai_board_id,
126134
}
127135

128136

@@ -185,13 +193,15 @@ def get_invokeai_settings(self) -> dict[str, str | None]:
185193
"url": config.invokeai_url,
186194
"username": config.invokeai_username,
187195
"password": config.invokeai_password,
196+
"board_id": config.invokeai_board_id,
188197
}
189198

190199
def set_invokeai_settings(
191200
self,
192201
url: str | None = None,
193202
username: str | None = None,
194203
password: str | None = None,
204+
board_id: str | None = None,
195205
) -> None:
196206
"""Update the InvokeAI connection settings.
197207
@@ -208,6 +218,7 @@ def _clean(value: str | None) -> str | None:
208218
config.invokeai_url = _clean(url)
209219
config.invokeai_username = _clean(username)
210220
config.invokeai_password = _clean(password)
221+
config.invokeai_board_id = _clean(board_id)
211222
self._config = config
212223
self.save_config()
213224
self._config = None
@@ -238,6 +249,7 @@ def load_config(self) -> Config:
238249
invokeai_url=config_data.get("invokeai_url"),
239250
invokeai_username=config_data.get("invokeai_username"),
240251
invokeai_password=config_data.get("invokeai_password"),
252+
invokeai_board_id=config_data.get("invokeai_board_id"),
241253
)
242254

243255
except Exception as e:

photomap/backend/routers/invoke.py

Lines changed: 166 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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

158158
class 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

166167
class 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")
202204
async 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+
225339
def _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

Comments
 (0)