Skip to content

Commit f9f2a32

Browse files
Feat(UI): Add LLM-powered prompt expansion and image-to-prompt features (#8899)
* Add LLM-powered prompt expansion and image-to-prompt features Adds two new buttons to the positive prompt area: - "Expand Prompt" uses a local TextLLM model (AutoModelForCausalLM) to expand brief prompts into detailed image generation prompts - "Image to Prompt" uses an existing LLaVA OneVision model to generate descriptive prompts from uploaded images Backend: new TextLLM model type with config, loader, pipeline wrapper, workflow node, and two new API endpoints (expand-prompt, image-to-prompt). Also fixes HuggingFace metadata fetch assertion error when file size is None. Frontend: ExpandPromptButton and ImageToPromptButton components with model picker popovers, RTK Query mutations, and model type hooks. Buttons only appear when compatible models are installed. * chore fix windows paths * Fix device mismatch for LLM inference and add CPU-only toggle for Text LLM models Derive the execution device from the loaded model parameters instead of the global TorchDevice chooser so that cpu_only models no longer receive GPU-bound inputs. Also expose the existing cpu_only setting in the frontend Model Manager for Text LLM models. * Harden LLM endpoints and add tests - Bound max_tokens to 1-2048 on ExpandPromptRequest to prevent OOM - Replace asserts with explicit type checks and proper HTTP status codes (404 for unknown models, 422 for wrong model type, 500 for unexpected) - Use float32 dtype for cpu_only TextLLM models instead of global fp16 - Add 16 tests for TextLLMPipeline and API request validation * Add Ctrl+Z undo for LLM prompt changes Saves the previous prompt before LLM overwrites it (Expand Prompt and Image to Prompt). Pressing Ctrl+Z in the prompt textarea restores the original prompt. Undo state auto-expires after 30 seconds and is cleared when the user types manually. * Add documentation and What's New entry for LLM prompt tools - Add docs/features/prompt-tools.md covering Expand Prompt, Image to Prompt, compatible models, Ctrl+Z undo, and the workflow node - Register new doc page in mkdocs.yml under Features - Add What's New item in en.json for the LLM Prompt Tools feature * fix: resolve merge conflict in mkdocs.yml nav * feat(ui): allow dragging gallery images onto prompt box for Image to Prompt Add drop target on the positive prompt textarea so users can drag images from the gallery directly into the prompt area. When dropped, the Image to Prompt popover opens automatically with the image pre-loaded, ready for description generation. * chore typegen * Fix typo in Z-Image Turbo diversity description * Fix three bugs in LLM/VLM utility endpoints Move torch.no_grad() from async endpoint into worker functions where inference actually runs, since the context manager does not carry across the thread boundary used by asyncio.to_thread(). Add threading.Lock around load_model() calls to serialize access to the thread-unsafe model loader, preventing race conditions from concurrent HTTP requests. Catch ImageFileNotFoundException in image_to_prompt and return 404 instead of letting it fall through to the blanket 500 handler. * Fix tokenizer validation, drag-drop dead end, and i18n for LLM prompt tools Validate tokenizer files at model probe time instead of deferring to runtime. Guard image drag-drop on the prompt textarea behind LLaVA model availability. Add missing modelManager.textLLM i18n key and replace all hardcoded strings in ImageToPromptButton and ExpandPromptButton with translation calls. * Add unit tests for promptUndo module * Fix typo in Z-Image Turbo diversity description * Chore fix typegen --------- Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com>
1 parent e77c0d1 commit f9f2a32

28 files changed

Lines changed: 1520 additions & 28 deletions

File tree

docs/features/prompt-tools.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# LLM Prompt Tools
2+
3+
InvokeAI includes two built-in tools that use local language models to help you write better prompts. Both tools appear as small buttons in the top-right corner of the positive prompt area and are only visible when you have a compatible model installed.
4+
5+
## Expand Prompt
6+
7+
Takes your short prompt and expands it into a detailed, vivid description suitable for image generation.
8+
9+
**How to use:**
10+
11+
1. Type a brief prompt (e.g. "a cat in a garden")
12+
2. Click the sparkle button in the prompt area
13+
3. Select a Text LLM model from the dropdown
14+
4. Click **Expand**
15+
5. Your prompt is replaced with the expanded version
16+
17+
**Compatible models:** Any HuggingFace model with a `ForCausalLM` architecture. Recommended options:
18+
19+
| Model | Size | HuggingFace ID |
20+
|-------|------|----------------|
21+
| Qwen2.5 1.5B Instruct | ~3 GB | `Qwen/Qwen2.5-1.5B-Instruct` |
22+
| Phi-3 Mini Instruct | ~7.5 GB | `microsoft/Phi-3-mini-4k-instruct` |
23+
| TinyLlama Chat | ~2 GB | `TinyLlama/TinyLlama-1.1B-Chat-v1.0` |
24+
25+
Install by pasting the HuggingFace ID into the Model Manager. The model is automatically detected as a **Text LLM** type.
26+
27+
## Image to Prompt
28+
29+
Upload an image and generate a descriptive prompt from it using a vision-language model.
30+
31+
**How to use:**
32+
33+
1. Click the image button in the prompt area
34+
2. Select a LLaVA OneVision model from the dropdown
35+
3. Click **Upload Image** and select an image
36+
4. Click **Generate Prompt**
37+
5. The generated description is set as your prompt
38+
39+
**Compatible models:** LLaVA OneVision models (already supported by InvokeAI).
40+
41+
## Undo
42+
43+
Both tools overwrite your current prompt. You can undo this change:
44+
45+
- Press **Ctrl+Z** (or **Cmd+Z** on macOS) in the prompt textarea within 30 seconds
46+
- The undo state is cleared when you start typing manually
47+
48+
## Workflow Node
49+
50+
A **Text LLM** node is also available in the workflow editor for use in automated pipelines. It accepts a prompt string and model selection as inputs and outputs the expanded text as a string.

invokeai/app/api/routers/utilities.py

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
1+
import asyncio
2+
import logging
3+
import threading
4+
from pathlib import Path
15
from typing import Optional, Union
26

7+
import torch
38
from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator
4-
from fastapi import Body
9+
from fastapi import Body, HTTPException
510
from fastapi.routing import APIRouter
6-
from pydantic import BaseModel
11+
from pydantic import BaseModel, Field
712
from pyparsing import ParseException
13+
from transformers import AutoProcessor, AutoTokenizer, LlavaOnevisionForConditionalGeneration, LlavaOnevisionProcessor
14+
15+
from invokeai.app.api.dependencies import ApiDependencies
16+
from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException
17+
from invokeai.app.services.model_records.model_records_base import UnknownModelException
18+
from invokeai.backend.llava_onevision_pipeline import LlavaOnevisionPipeline
19+
from invokeai.backend.model_manager.taxonomy import ModelType
20+
from invokeai.backend.text_llm_pipeline import DEFAULT_SYSTEM_PROMPT, TextLLMPipeline
21+
from invokeai.backend.util.devices import TorchDevice
22+
23+
logger = logging.getLogger(__name__)
824

925
utilities_router = APIRouter(prefix="/v1/utilities", tags=["utilities"])
1026

27+
# The underlying model loader is not thread-safe, so we serialize load_model calls.
28+
_model_load_lock = threading.Lock()
29+
1130

1231
class DynamicPromptsResponse(BaseModel):
1332
prompts: list[str]
@@ -42,3 +61,160 @@ async def parse_dynamicprompts(
4261
prompts = [prompt]
4362
error = str(e)
4463
return DynamicPromptsResponse(prompts=prompts if prompts else [""], error=error)
64+
65+
66+
# --- Expand Prompt ---
67+
68+
69+
class ExpandPromptRequest(BaseModel):
70+
prompt: str
71+
model_key: str
72+
max_tokens: int = Field(default=300, ge=1, le=2048)
73+
system_prompt: str | None = None
74+
75+
76+
class ExpandPromptResponse(BaseModel):
77+
expanded_prompt: str
78+
error: str | None = None
79+
80+
81+
def _resolve_model_path(model_config_path: str) -> Path:
82+
"""Resolve a model config path to an absolute path."""
83+
model_path = Path(model_config_path)
84+
if model_path.is_absolute():
85+
return model_path.resolve()
86+
base_models_path = ApiDependencies.invoker.services.configuration.models_path
87+
return (base_models_path / model_path).resolve()
88+
89+
90+
def _run_expand_prompt(prompt: str, model_key: str, max_tokens: int, system_prompt: str | None) -> str:
91+
"""Run text LLM inference synchronously (called from thread)."""
92+
model_manager = ApiDependencies.invoker.services.model_manager
93+
model_config = model_manager.store.get_model(model_key)
94+
95+
if model_config.type != ModelType.TextLLM:
96+
raise ValueError(f"Model '{model_key}' is not a TextLLM model (got {model_config.type})")
97+
98+
with _model_load_lock:
99+
loaded_model = model_manager.load.load_model(model_config)
100+
101+
with torch.no_grad(), loaded_model.model_on_device() as (_, model):
102+
model_abs_path = _resolve_model_path(model_config.path)
103+
tokenizer = AutoTokenizer.from_pretrained(model_abs_path, local_files_only=True)
104+
105+
pipeline = TextLLMPipeline(model, tokenizer)
106+
model_device = next(model.parameters()).device
107+
output = pipeline.run(
108+
prompt=prompt,
109+
system_prompt=system_prompt or DEFAULT_SYSTEM_PROMPT,
110+
max_new_tokens=max_tokens,
111+
device=model_device,
112+
dtype=TorchDevice.choose_torch_dtype(),
113+
)
114+
115+
return output
116+
117+
118+
@utilities_router.post(
119+
"/expand-prompt",
120+
operation_id="expand_prompt",
121+
responses={
122+
200: {"model": ExpandPromptResponse},
123+
},
124+
)
125+
async def expand_prompt(body: ExpandPromptRequest) -> ExpandPromptResponse:
126+
"""Expand a brief prompt into a detailed image generation prompt using a text LLM."""
127+
try:
128+
expanded = await asyncio.to_thread(
129+
_run_expand_prompt,
130+
body.prompt,
131+
body.model_key,
132+
body.max_tokens,
133+
body.system_prompt,
134+
)
135+
return ExpandPromptResponse(expanded_prompt=expanded)
136+
except UnknownModelException:
137+
raise HTTPException(status_code=404, detail=f"Model '{body.model_key}' not found")
138+
except ValueError as e:
139+
raise HTTPException(status_code=422, detail=str(e))
140+
except Exception as e:
141+
logger.error(f"Error expanding prompt: {e}")
142+
raise HTTPException(status_code=500, detail=str(e))
143+
144+
145+
# --- Image to Prompt ---
146+
147+
148+
class ImageToPromptRequest(BaseModel):
149+
image_name: str
150+
model_key: str
151+
instruction: str = "Describe this image in detail for use as an AI image generation prompt."
152+
153+
154+
class ImageToPromptResponse(BaseModel):
155+
prompt: str
156+
error: str | None = None
157+
158+
159+
def _run_image_to_prompt(image_name: str, model_key: str, instruction: str) -> str:
160+
"""Run LLaVA OneVision inference synchronously (called from thread)."""
161+
model_manager = ApiDependencies.invoker.services.model_manager
162+
model_config = model_manager.store.get_model(model_key)
163+
164+
if model_config.type != ModelType.LlavaOnevision:
165+
raise ValueError(f"Model '{model_key}' is not a LLaVA OneVision model (got {model_config.type})")
166+
167+
with _model_load_lock:
168+
loaded_model = model_manager.load.load_model(model_config)
169+
170+
# Load the image from InvokeAI's image store
171+
image = ApiDependencies.invoker.services.images.get_pil_image(image_name)
172+
image = image.convert("RGB")
173+
174+
with torch.no_grad(), loaded_model.model_on_device() as (_, model):
175+
if not isinstance(model, LlavaOnevisionForConditionalGeneration):
176+
raise TypeError(f"Expected LlavaOnevisionForConditionalGeneration, got {type(model).__name__}")
177+
178+
model_abs_path = _resolve_model_path(model_config.path)
179+
processor = AutoProcessor.from_pretrained(model_abs_path, local_files_only=True)
180+
if not isinstance(processor, LlavaOnevisionProcessor):
181+
raise TypeError(f"Expected LlavaOnevisionProcessor, got {type(processor).__name__}")
182+
183+
pipeline = LlavaOnevisionPipeline(model, processor)
184+
model_device = next(model.parameters()).device
185+
output = pipeline.run(
186+
prompt=instruction,
187+
images=[image],
188+
device=model_device,
189+
dtype=TorchDevice.choose_torch_dtype(),
190+
)
191+
192+
return output
193+
194+
195+
@utilities_router.post(
196+
"/image-to-prompt",
197+
operation_id="image_to_prompt",
198+
responses={
199+
200: {"model": ImageToPromptResponse},
200+
},
201+
)
202+
async def image_to_prompt(body: ImageToPromptRequest) -> ImageToPromptResponse:
203+
"""Generate a descriptive prompt from an image using a vision-language model."""
204+
try:
205+
prompt = await asyncio.to_thread(
206+
_run_image_to_prompt,
207+
body.image_name,
208+
body.model_key,
209+
body.instruction,
210+
)
211+
return ImageToPromptResponse(prompt=prompt)
212+
except UnknownModelException:
213+
raise HTTPException(status_code=404, detail=f"Model '{body.model_key}' not found")
214+
except ImageFileNotFoundException:
215+
raise HTTPException(status_code=404, detail=f"Image '{body.image_name}' not found")
216+
except (ValueError, TypeError) as e:
217+
raise HTTPException(status_code=422, detail=str(e))
218+
except Exception as e:
219+
logger.error(f"Error generating prompt from image: {e}")
220+
raise HTTPException(status_code=500, detail=str(e))

invokeai/app/invocations/fields.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ class FieldDescriptions:
229229
instantx_control_mode = "The control mode for InstantX ControlNet union models. Ignored for other ControlNet models. The standard mapping is: canny (0), tile (1), depth (2), blur (3), pose (4), gray (5), low quality (6). Negative values will be treated as 'None'."
230230
flux_redux_conditioning = "FLUX Redux conditioning tensor"
231231
vllm_model = "The VLLM model to use"
232+
text_llm_model = "The text language model to use for text generation"
232233
flux_fill_conditioning = "FLUX Fill conditioning tensor"
233234
flux_kontext_conditioning = "FLUX Kontext conditioning (reference image)"
234235

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import torch
2+
from transformers import AutoTokenizer
3+
4+
from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
5+
from invokeai.app.invocations.fields import FieldDescriptions, InputField, UIComponent
6+
from invokeai.app.invocations.model import ModelIdentifierField
7+
from invokeai.app.invocations.primitives import StringOutput
8+
from invokeai.app.services.shared.invocation_context import InvocationContext
9+
from invokeai.backend.model_manager.taxonomy import ModelType
10+
from invokeai.backend.text_llm_pipeline import DEFAULT_SYSTEM_PROMPT, TextLLMPipeline
11+
from invokeai.backend.util.devices import TorchDevice
12+
13+
14+
@invocation(
15+
"text_llm",
16+
title="Text LLM",
17+
tags=["llm", "text", "prompt"],
18+
category="llm",
19+
version="1.0.0",
20+
classification=Classification.Beta,
21+
)
22+
class TextLLMInvocation(BaseInvocation):
23+
"""Run a text language model to generate or expand text (e.g. for prompt expansion)."""
24+
25+
prompt: str = InputField(
26+
default="",
27+
description="Input text prompt.",
28+
ui_component=UIComponent.Textarea,
29+
)
30+
system_prompt: str = InputField(
31+
default=DEFAULT_SYSTEM_PROMPT,
32+
description="System prompt that guides the model's behavior.",
33+
ui_component=UIComponent.Textarea,
34+
)
35+
text_llm_model: ModelIdentifierField = InputField(
36+
title="Text LLM Model",
37+
description=FieldDescriptions.text_llm_model,
38+
ui_model_type=ModelType.TextLLM,
39+
)
40+
max_tokens: int = InputField(
41+
default=300,
42+
ge=1,
43+
le=2048,
44+
description="Maximum number of tokens to generate.",
45+
)
46+
47+
@torch.no_grad()
48+
def invoke(self, context: InvocationContext) -> StringOutput:
49+
model_config = context.models.get_config(self.text_llm_model)
50+
51+
with context.models.load(self.text_llm_model).model_on_device() as (_, model):
52+
model_abs_path = context.models.get_absolute_path(model_config)
53+
tokenizer = AutoTokenizer.from_pretrained(model_abs_path, local_files_only=True)
54+
55+
pipeline = TextLLMPipeline(model, tokenizer)
56+
model_device = next(model.parameters()).device
57+
output = pipeline.run(
58+
prompt=self.prompt,
59+
system_prompt=self.system_prompt,
60+
max_new_tokens=self.max_tokens,
61+
device=model_device,
62+
dtype=TorchDevice.choose_torch_dtype(),
63+
)
64+
65+
return StringOutput(value=output)

invokeai/backend/model_manager/configs/factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
T2IAdapter_Diffusers_SDXL_Config,
9898
)
9999
from invokeai.backend.model_manager.configs.t5_encoder import T5Encoder_BnBLLMint8_Config, T5Encoder_T5Encoder_Config
100+
from invokeai.backend.model_manager.configs.text_llm import TextLLM_Diffusers_Config
100101
from invokeai.backend.model_manager.configs.textual_inversion import (
101102
TI_File_SD1_Config,
102103
TI_File_SD2_Config,
@@ -269,6 +270,7 @@
269270
Annotated[SigLIP_Diffusers_Config, SigLIP_Diffusers_Config.get_tag()],
270271
Annotated[FLUXRedux_Checkpoint_Config, FLUXRedux_Checkpoint_Config.get_tag()],
271272
Annotated[LlavaOnevision_Diffusers_Config, LlavaOnevision_Diffusers_Config.get_tag()],
273+
Annotated[TextLLM_Diffusers_Config, TextLLM_Diffusers_Config.get_tag()],
272274
Annotated[ExternalApiModelConfig, ExternalApiModelConfig.get_tag()],
273275
# Unknown model (fallback)
274276
Annotated[Unknown_Config, Unknown_Config.get_tag()],
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from typing import (
2+
Literal,
3+
Self,
4+
)
5+
6+
from pydantic import Field
7+
from typing_extensions import Any
8+
9+
from invokeai.backend.model_manager.configs.base import Config_Base, Diffusers_Config_Base
10+
from invokeai.backend.model_manager.configs.identification_utils import (
11+
NotAMatchError,
12+
common_config_paths,
13+
get_class_name_from_config_dict_or_raise,
14+
raise_for_override_fields,
15+
raise_if_not_dir,
16+
)
17+
from invokeai.backend.model_manager.model_on_disk import ModelOnDisk
18+
from invokeai.backend.model_manager.taxonomy import (
19+
BaseModelType,
20+
ModelType,
21+
)
22+
23+
24+
class TextLLM_Diffusers_Config(Diffusers_Config_Base, Config_Base):
25+
"""Model config for text-only causal language models (e.g. Llama, Phi, Qwen, Mistral)."""
26+
27+
type: Literal[ModelType.TextLLM] = Field(default=ModelType.TextLLM)
28+
base: Literal[BaseModelType.Any] = Field(default=BaseModelType.Any)
29+
cpu_only: bool | None = Field(default=None, description="Whether this model should run on CPU only")
30+
31+
@classmethod
32+
def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -> Self:
33+
raise_if_not_dir(mod)
34+
35+
raise_for_override_fields(cls, override_fields)
36+
37+
# Check that the model's architecture is a causal language model.
38+
# This covers LlamaForCausalLM, PhiForCausalLM, Phi3ForCausalLM, Qwen2ForCausalLM,
39+
# MistralForCausalLM, GemmaForCausalLM, GPTNeoXForCausalLM, etc.
40+
class_name = get_class_name_from_config_dict_or_raise(common_config_paths(mod.path))
41+
if not class_name.endswith("ForCausalLM"):
42+
raise NotAMatchError(f"model architecture '{class_name}' is not a causal language model")
43+
44+
# Verify tokenizer files exist to avoid runtime failures
45+
tokenizer_files = {"tokenizer.json", "tokenizer.model", "tokenizer_config.json"}
46+
if not any((mod.path / f).exists() for f in tokenizer_files):
47+
raise NotAMatchError(
48+
f"no tokenizer files found in '{mod.path}' "
49+
f"(expected at least one of: {', '.join(sorted(tokenizer_files))})"
50+
)
51+
52+
return cls(**override_fields)

0 commit comments

Comments
 (0)