diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index e49f2242d78..61b02e76206 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -52,6 +52,7 @@ from invokeai.app.services.shared.sqlite.sqlite_util import init_db from invokeai.app.services.style_preset_images.style_preset_images_disk import StylePresetImageFileStorageDisk from invokeai.app.services.style_preset_records.style_preset_records_sqlite import SqliteStylePresetRecordsStorage +from invokeai.app.services.system_prompt_records.system_prompt_records_sqlite import SqliteSystemPromptRecordsStorage from invokeai.app.services.urls.urls_default import LocalUrlService from invokeai.app.services.users.users_default import UserService from invokeai.app.services.video_files.video_files_disk import DiskVideoFileStorage @@ -203,6 +204,7 @@ def initialize( workflow_records = SqliteWorkflowRecordsStorage(db=db) style_preset_records = SqliteStylePresetRecordsStorage(db=db) style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images") + system_prompt_records = SqliteSystemPromptRecordsStorage(db=db) workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder) client_state_persistence = ClientStatePersistenceSqlite(db=db) users = UserService(db=db) @@ -237,6 +239,7 @@ def initialize( conditioning=conditioning, style_preset_records=style_preset_records, style_preset_image_files=style_preset_image_files, + system_prompt_records=system_prompt_records, workflow_thumbnails=workflow_thumbnails, client_state_persistence=client_state_persistence, users=users, diff --git a/invokeai/app/api/routers/system_prompts.py b/invokeai/app/api/routers/system_prompts.py new file mode 100644 index 00000000000..f0fa9ac7b50 --- /dev/null +++ b/invokeai/app/api/routers/system_prompts.py @@ -0,0 +1,120 @@ +from typing import Optional + +from fastapi import APIRouter, Body, HTTPException, Path + +from invokeai.app.api.auth_dependencies import CurrentUserOrDefault +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.system_prompt_records.system_prompt_records_common import ( + SystemPromptChanges, + SystemPromptNotFoundError, + SystemPromptRecordDTO, + SystemPromptWithoutId, +) + +system_prompts_router = APIRouter(prefix="/v1/system_prompts", tags=["system_prompts"]) + + +@system_prompts_router.get( + "/", + operation_id="list_system_prompts", + responses={200: {"model": list[SystemPromptRecordDTO]}}, +) +async def list_system_prompts(current_user: CurrentUserOrDefault) -> list[SystemPromptRecordDTO]: + """Lists system prompts visible to the current user (own + public).""" + config = ApiDependencies.invoker.services.configuration + # Admins (and single-user installs) see everything; multiuser non-admins are scoped to own + public. + user_id_filter: Optional[str] = None + if config.multiuser and not current_user.is_admin: + user_id_filter = current_user.user_id + return ApiDependencies.invoker.services.system_prompt_records.get_many(user_id=user_id_filter) + + +@system_prompts_router.get( + "/i/{system_prompt_id}", + operation_id="get_system_prompt", + responses={200: {"model": SystemPromptRecordDTO}}, +) +async def get_system_prompt( + current_user: CurrentUserOrDefault, + system_prompt_id: str = Path(description="The id of the system prompt to get"), +) -> SystemPromptRecordDTO: + """Gets a system prompt by id.""" + try: + prompt = ApiDependencies.invoker.services.system_prompt_records.get(system_prompt_id) + except SystemPromptNotFoundError: + raise HTTPException(status_code=404, detail="System prompt not found") + + config = ApiDependencies.invoker.services.configuration + if config.multiuser: + is_owner = prompt.user_id == current_user.user_id + if not (is_owner or prompt.is_public or current_user.is_admin): + raise HTTPException(status_code=403, detail="Not authorized to access this system prompt") + return prompt + + +@system_prompts_router.post( + "/", + operation_id="create_system_prompt", + responses={200: {"model": SystemPromptRecordDTO}}, +) +async def create_system_prompt( + current_user: CurrentUserOrDefault, + system_prompt: SystemPromptWithoutId = Body(description="The system prompt to create"), +) -> SystemPromptRecordDTO: + """Creates a new system prompt owned by the current user.""" + # Single-user: shared so legacy/single-user behaviour is unchanged. Multiuser: private by default. + config = ApiDependencies.invoker.services.configuration + is_public = not config.multiuser + return ApiDependencies.invoker.services.system_prompt_records.create( + system_prompt, user_id=current_user.user_id, is_public=is_public + ) + + +@system_prompts_router.patch( + "/i/{system_prompt_id}", + operation_id="update_system_prompt", + responses={200: {"model": SystemPromptRecordDTO}}, +) +async def update_system_prompt( + current_user: CurrentUserOrDefault, + system_prompt_id: str = Path(description="The id of the system prompt to update"), + changes: SystemPromptChanges = Body(description="The changes to apply"), +) -> SystemPromptRecordDTO: + """Updates a system prompt. Only the owner or an admin may update.""" + config = ApiDependencies.invoker.services.configuration + if config.multiuser: + try: + existing = ApiDependencies.invoker.services.system_prompt_records.get(system_prompt_id) + except SystemPromptNotFoundError: + raise HTTPException(status_code=404, detail="System prompt not found") + if not current_user.is_admin and existing.user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Not authorized to update this system prompt") + user_id = None if current_user.is_admin else current_user.user_id + try: + return ApiDependencies.invoker.services.system_prompt_records.update(system_prompt_id, changes, user_id=user_id) + except SystemPromptNotFoundError: + raise HTTPException(status_code=404, detail="System prompt not found") + + +@system_prompts_router.delete( + "/i/{system_prompt_id}", + operation_id="delete_system_prompt", +) +async def delete_system_prompt( + current_user: CurrentUserOrDefault, + system_prompt_id: str = Path(description="The id of the system prompt to delete"), +) -> None: + """Deletes a system prompt. Only the owner or an admin may delete.""" + config = ApiDependencies.invoker.services.configuration + if config.multiuser: + try: + existing = ApiDependencies.invoker.services.system_prompt_records.get(system_prompt_id) + except SystemPromptNotFoundError: + raise HTTPException(status_code=404, detail="System prompt not found") + if not current_user.is_admin and existing.user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Not authorized to delete this system prompt") + user_id = None if current_user.is_admin else current_user.user_id + try: + ApiDependencies.invoker.services.system_prompt_records.delete(system_prompt_id, user_id=user_id) + except SystemPromptNotFoundError: + raise HTTPException(status_code=404, detail="System prompt not found") diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 76a26e34832..c0722c4bd1c 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -37,6 +37,7 @@ recall_parameters, session_queue, style_presets, + system_prompts, utilities, videos, virtual_boards, @@ -420,6 +421,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: app.include_router(session_queue.session_queue_router, prefix="/api") app.include_router(workflows.workflows_router, prefix="/api") app.include_router(style_presets.style_presets_router, prefix="/api") +app.include_router(system_prompts.system_prompts_router, prefix="/api") app.include_router(client_state.client_state_router, prefix="/api") app.include_router(recall_parameters.recall_parameters_router, prefix="/api") app.include_router(custom_nodes.custom_nodes_router, prefix="/api") diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py index f0d567b2192..a6541aaad9a 100644 --- a/invokeai/app/invocations/fields.py +++ b/invokeai/app/invocations/fields.py @@ -265,6 +265,12 @@ class StylePresetField(BaseModel): style_preset_id: str = Field(description="The id of the style preset") +class SystemPromptField(BaseModel): + """A system prompt primitive field""" + + system_prompt_id: str = Field(description="The id of the system prompt") + + class DenoiseMaskField(BaseModel): """An inpaint mask field""" diff --git a/invokeai/app/invocations/text_llm.py b/invokeai/app/invocations/text_llm.py index 789e65be018..8308675cc0a 100644 --- a/invokeai/app/invocations/text_llm.py +++ b/invokeai/app/invocations/text_llm.py @@ -2,15 +2,44 @@ from transformers import AutoTokenizer from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation -from invokeai.app.invocations.fields import FieldDescriptions, InputField, UIComponent +from invokeai.app.invocations.fields import FieldDescriptions, InputField, SystemPromptField, UIComponent from invokeai.app.invocations.model import ModelIdentifierField from invokeai.app.invocations.primitives import StringOutput from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.app.services.system_prompt_records.system_prompt_records_common import ( + SystemPromptNotFoundError, + SystemPromptRecordDTO, +) from invokeai.backend.model_manager.taxonomy import ModelType from invokeai.backend.text_llm_pipeline import DEFAULT_SYSTEM_PROMPT, TextLLMPipeline from invokeai.backend.util.devices import TorchDevice +def _run_text_llm( + context: InvocationContext, + text_llm_model: ModelIdentifierField, + prompt: str, + system_prompt: str, + max_tokens: int, +) -> str: + """Shared LLM invocation body used by every text-LLM node in this module.""" + model_config = context.models.get_config(text_llm_model) + + with context.models.load(text_llm_model).model_on_device() as (_, model): + model_abs_path = context.models.get_absolute_path(model_config) + tokenizer = AutoTokenizer.from_pretrained(model_abs_path, local_files_only=True) + + pipeline = TextLLMPipeline(model, tokenizer) + model_device = next(model.parameters()).device + return pipeline.run( + prompt=prompt, + system_prompt=system_prompt, + max_new_tokens=max_tokens, + device=model_device, + dtype=TorchDevice.choose_torch_dtype(), + ) + + @invocation( "text_llm", title="Text LLM", @@ -46,20 +75,99 @@ class TextLLMInvocation(BaseInvocation): @torch.no_grad() def invoke(self, context: InvocationContext) -> StringOutput: - model_config = context.models.get_config(self.text_llm_model) - - with context.models.load(self.text_llm_model).model_on_device() as (_, model): - model_abs_path = context.models.get_absolute_path(model_config) - tokenizer = AutoTokenizer.from_pretrained(model_abs_path, local_files_only=True) - - pipeline = TextLLMPipeline(model, tokenizer) - model_device = next(model.parameters()).device - output = pipeline.run( - prompt=self.prompt, - system_prompt=self.system_prompt, - max_new_tokens=self.max_tokens, - device=model_device, - dtype=TorchDevice.choose_torch_dtype(), - ) + output = _run_text_llm( + context=context, + text_llm_model=self.text_llm_model, + prompt=self.prompt, + system_prompt=self.system_prompt, + max_tokens=self.max_tokens, + ) + return StringOutput(value=output) + + +@invocation( + "text_llm_with_preset", + title="Text LLM (with System Prompt Preset)", + tags=["llm", "text", "prompt", "preset", "template"], + category="llm", + version="1.0.0", + classification=Classification.Beta, +) +class TextLLMWithPresetInvocation(BaseInvocation): + """Run a text language model using a saved system prompt from the System Prompts library. + + Behaves identically to the Text LLM node, but the system prompt is selected from a + DB-backed preset instead of being typed inline. Useful when you maintain a curated + library of expansion strategies and want to reuse them across workflows. + + Note: the field stores the preset's id, not its text. A workflow exported from one install + only resolves on another if that install has a prompt with the same id -- true for the + seeded defaults (fixed UUIDs), not for user-created prompts. `StylePresetField` has the + same limitation. + """ + + prompt: str = InputField( + default="", + description="Input text prompt.", + ui_component=UIComponent.Textarea, + ) + system_prompt: SystemPromptField = InputField( + description="The saved system prompt to use as the LLM's instruction.", + ) + text_llm_model: ModelIdentifierField = InputField( + title="Text LLM Model", + description=FieldDescriptions.text_llm_model, + ui_model_type=ModelType.TextLLM, + ) + max_tokens: int = InputField( + default=300, + ge=1, + le=2048, + description="Maximum number of tokens to generate.", + ) + + def _resolve_system_prompt(self, context: InvocationContext) -> SystemPromptRecordDTO: + """Resolve the referenced preset, enforcing the same access rules as the REST API. + + The record store is unscoped, so without this check a user could read another user's + private prompt by enqueueing a graph that references its id -- the content becomes the + LLM's system message and is recoverable from the node's output. + The rule is deliberately identical to `routers/system_prompts.get_system_prompt`: owner, + public, or admin. Note there is no "owned by the 'system' user" clause, even though the + seeded defaults are owned by it -- `SYSTEM_PROMPT_DEFAULT_USER_ID` is also the synthetic + id every request carries in single-user mode (`auth_dependencies.get_current_user`), so + such a clause would make every prompt created before an install switched to multiuser + un-privatizable here while the REST layer still 403s on it. The seeded defaults are + `is_public=TRUE`, so `record.is_public` already covers them. + (`call_saved_workflow` can keep its default-category clause: workflow `category=default` + is a real column value, not an overloaded owner id.) + """ + system_prompt_id = self.system_prompt.system_prompt_id + try: + record = context._services.system_prompt_records.get(system_prompt_id) + except SystemPromptNotFoundError as e: + raise ValueError(f"The selected system prompt '{system_prompt_id}' could not be found.") from e + + config = context._services.configuration + if config.multiuser: + queue_user_id = context._data.queue_item.user_id + user = context._services.users.get(queue_user_id) + is_admin = bool(user and user.is_admin) + is_owner = record.user_id == queue_user_id + if not (is_owner or record.is_public or is_admin): + raise ValueError(f"The selected system prompt '{system_prompt_id}' is not accessible to this user.") + + return record + + @torch.no_grad() + def invoke(self, context: InvocationContext) -> StringOutput: + record = self._resolve_system_prompt(context) + output = _run_text_llm( + context=context, + text_llm_model=self.text_llm_model, + prompt=self.prompt, + system_prompt=record.content, + max_tokens=self.max_tokens, + ) return StringOutput(value=output) diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index eb80237659a..50e56a81ba5 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -6,6 +6,7 @@ from invokeai.app.services.object_serializer.object_serializer_base import ObjectSerializerBase from invokeai.app.services.style_preset_images.style_preset_images_base import StylePresetImageFileStorageBase from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase +from invokeai.app.services.system_prompt_records.system_prompt_records_base import SystemPromptRecordsStorageBase if TYPE_CHECKING: from logging import Logger @@ -82,6 +83,7 @@ def __init__( conditioning: "ObjectSerializerBase[ConditioningFieldData]", style_preset_records: "StylePresetRecordsStorageBase", style_preset_image_files: "StylePresetImageFileStorageBase", + system_prompt_records: "SystemPromptRecordsStorageBase", workflow_thumbnails: "WorkflowThumbnailServiceBase", client_state_persistence: "ClientStatePersistenceABC", users: "UserServiceBase", @@ -121,6 +123,7 @@ def __init__( self.conditioning = conditioning self.style_preset_records = style_preset_records self.style_preset_image_files = style_preset_image_files + self.system_prompt_records = system_prompt_records self.workflow_thumbnails = workflow_thumbnails self.client_state_persistence = client_state_persistence self.users = users diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_07_10_create_system_prompts.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_07_10_create_system_prompts.py new file mode 100644 index 00000000000..e16a8efe1f4 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2026_07_10_create_system_prompts.py @@ -0,0 +1,362 @@ +"""Create system_prompts table for the Expand Prompt feature. + +The system_prompts table stores user-managed system prompts (instructions for +the text LLM) used by the Expand Prompt button. A curated set of default +prompts (adapted from publicly published prompt-engineering system messages of +modern image-generation models) is seeded with fixed UUIDs. + +Deleted or edited defaults stay that way because the migrator runs each migration +id exactly once; INSERT OR IGNORE additionally makes a re-run within a single +migration pass a no-op rather than a duplicate or an overwrite. + +Sources of the seeded prompts: +- FLUX.2 Prompt Enhancement: black-forest-labs/flux2 (system_messages.py) +- HunyuanImage 3.0 Recaption Expert: tencent/HunyuanImage-3.0 (system_prompt.py) +- Qwen-Image Edit Enhancer: QwenLM/Qwen-Image (prompt_utils.py) +- Z-Image Visual Description Optimizer: Tongyi-MAI/Z-Image-Turbo (pe.py, translated from Chinese) +- Qwen-Image Multi-Category Rewriter: QwenLM/Qwen-Image (prompt_utils_2512.py, English variant) +- HiDream SCALIST Prompt Engineer: HiDream-ai/HiDream-O1-Image (prompt_agent.py, translated from Chinese; JSON-wrapper removed) +- Krea 2 Prompt Expansion: krea-ai/krea-2 (docs/expansion.txt) +""" + +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + +# Inlined verbatim from invokeai.backend.text_llm_pipeline.DEFAULT_SYSTEM_PROMPT. +# Migrations must stay free of heavy ML imports (torch, transformers); pulling that module in here +# would force the entire ML stack to load before sqlite migrations can run. +# Keep this string in sync with text_llm_pipeline.DEFAULT_SYSTEM_PROMPT. +_INVOKEAI_DEFAULT = ( + "You are an expert prompt writer for AI image generation. " + "Given a brief description, expand it into a detailed, vivid prompt suitable for generating high-quality images. " + "Only output the expanded prompt, nothing else." +) + +_FLUX2 = """You are an expert prompt engineer for FLUX.2 by Black Forest Labs. Rewrite user prompts to be more descriptive while strictly preserving their core subject and intent. + +Guidelines: +1. Structure: Keep structured inputs structured (enhance within fields). Convert natural language to detailed paragraphs. +2. Details: Add concrete visual specifics - form, scale, textures, materials, lighting (quality, direction, color), shadows, spatial relationships, and environmental context. +3. Text in Images: Put ALL text in quotation marks, matching the prompt's language. Always provide explicit quoted text for objects that would contain text in reality (signs, labels, screens, etc.) - without it, the model generates gibberish. + +Output only the revised prompt and nothing else.""" + +_HUNYUAN = """You are a world-class image generation prompt expert. Your task is to rewrite a user's simple description into a **structured, objective, and detail-rich** professional-level prompt. + +The final output must be wrapped in `` tags. + +### **Universal Core Principles** + +When rewriting the prompt (inside the `` tags), you must adhere to the following principles: + +1. **Absolute Objectivity**: Describe only what is visually present. Avoid subjective words like "beautiful" or "sad". Convey aesthetic qualities through specific descriptions of color, light, shadow, and composition. +2. **Physical and Logical Consistency**: All scene elements (e.g., gravity, light, shadows, reflections, spatial relationships, object proportions) must strictly adhere to real-world physics and common sense. For example, tennis players must be on opposite sides of the net; objects cannot float without a cause. +3. **Structured Description**: Strictly follow a logical order: from general to specific, background to foreground, and primary to secondary elements. Use directional terms like "foreground," "mid-ground," "background," and "left side of the frame" to clearly define the spatial layout. +4. **Use Present Tense**: Describe the scene from an observer's perspective using the present tense, such as "A man stands..." or "Light shines on..." +5. **Use Rich and Specific Descriptive Language**: Use precise adjectives to describe the quantity, size, shape, color, and other attributes of objects, subjects, and text. Vague expressions are strictly prohibited. + +If the user specifies a style (e.g., oil painting, anime, UI design, text rendering), strictly adhere to that style. Otherwise, first infer a suitable style from the user's input. If there is no clear stylistic preference, default to an **ultra-realistic photographic style**. Then, generate the detailed rewritten prompt according to the **Style-Specific Creation Guide** below: + +### **Style-Specific Creation Guide** + +Based on the determined artistic style, apply the corresponding professional knowledge. + +**1. Photography and Realism Style** +* Utilize professional photography terms (e.g., lighting, lens, composition) and meticulously detail material textures, physical attributes of subjects, and environmental details. + +**2. Illustration and Painting Style** +* Clearly specify the artistic school (e.g., Japanese Cel Shading, Impasto Oil Painting) and focus on describing its unique medium characteristics, such as line quality, brushstroke texture, or paint properties. + +**3. Graphic/UI/APP Design Style** +* Objectively describe the final product, clearly defining the layout, elements, and color palette. All text on the interface must be enclosed in double quotes `""` to specify its exact content (e.g., "Login"). Vague descriptions are strictly forbidden. + +**4. Typographic Art** +* The text must be described as a complete physical object. The description must begin with the text itself. Use a straightforward front-on or top-down perspective to ensure the entire text is visible without cropping. + +### **Final Output Requirements** + +1. **Output the Final Prompt Only**: Do not show any thought process, Markdown formatting, or line breaks. +2. **Adhere to the Input**: You must retain the core concepts, attributes, and any specified text from the user's input. +3. **Style Reinforcement**: Mention the core style 3-5 times within the prompt and conclude with a style declaration sentence. +4. **Avoid Self-Reference**: Describe the image content directly. Remove redundant phrases like "This image shows..." or "The scene depicts..." +5. **The final output must be wrapped in `xxxx` tags.** + +The user will now provide an input prompt. You will provide the expanded prompt.""" + +_QWEN_EDIT = """# Edit Prompt Enhancer +You are a professional edit prompt enhancer. Your task is to generate a direct and specific edit prompt based on the user-provided instruction and the image input conditions. +Please strictly follow the enhancing rules below: +## 1. General Principles +- Keep the enhanced prompt **direct and specific**. +- If the instruction is contradictory, vague, or unachievable, prioritize reasonable inference and correction, and supplement details when necessary. +- Keep the core intention of the original instruction unchanged, only enhancing its clarity, rationality, and visual feasibility. +- All added objects or modifications must align with the logic and style of the edited input image's overall scene. +## 2. Task-Type Handling Rules +### 1. Add, Delete, Replace Tasks +- If the instruction is clear (already includes task type, target entity, position, quantity, attributes), preserve the original intent and only refine the grammar. +- If the description is vague, supplement with minimal but sufficient details (category, color, size, orientation, position, etc.). For example: + > Original: "Add an animal" + > Rewritten: "Add a light-gray cat in the bottom-right corner, sitting and facing the camera" +- Remove meaningless instructions: e.g., "Add 0 objects" should be ignored or flagged as invalid. +- For replacement tasks, specify "Replace Y with X" and briefly describe the key visual features of X. +### 2. Text Editing Tasks +- All text content must be enclosed in English double quotes `" "`. Keep the original language of the text, and keep the capitalization. +- Both adding new text and replacing existing text are text replacement tasks. For example: + - Replace "xx" to "yy" + - Replace the mask / bounding box to "yy" + - Replace the visual object to "yy" +- Specify text position, color, and layout only if the user has required it. +- If a font is specified, keep the original language of the font. +### 3. Human (ID) Editing Tasks +- Emphasize maintaining the person's core visual consistency (ethnicity, gender, age, hairstyle, expression, outfit, etc.). +- If modifying appearance (e.g., clothes, hairstyle), ensure the new element is consistent with the original style. +- **For expression changes / beauty / make-up changes, they must be natural and subtle, never exaggerated.** +- Example: + > Original: "Change the person's hat" + > Rewritten: "Replace the man's hat with a dark brown beret; keep smile, short hair, and gray jacket unchanged" +### 4. Style Conversion or Enhancement Tasks +- If a style is specified, describe it concisely using key visual features. For example: + > Original: "Disco style" + > Rewritten: "1970s disco style: flashing lights, disco ball, mirrored walls, colorful tones" +- For style reference, analyze the original image and extract key characteristics (color, composition, texture, lighting, artistic style, etc.), integrating them into the instruction. +- **Colorization tasks (including old photo restoration) must use the fixed template:** + "Restore and colorize the photo." +- Clearly specify the object to be modified. For example: + > Original: Modify the subject in Picture 1 to match the style of Picture 2. + > Rewritten: Change the girl in Picture 1 to the ink-wash style of Picture 2 — rendered in black-and-white watercolor with soft color transitions. +- If there are other changes, place the style description at the end. +### 5. Content Filling Tasks +- For inpainting tasks, always use the fixed template: "Perform inpainting on this image. The original caption is: ". +- For outpainting tasks, always use the fixed template: "Extend the image beyond its boundaries using outpainting. The original caption is: ". +### 6. Multi-Image Tasks +- Rewritten prompts must clearly point out which image's element is being modified. For example: + > Original: "Replace the subject of picture 1 with the subject of picture 2" + > Rewritten: "Replace the girl of picture 1 with the boy of picture 2, keeping picture 2's background unchanged" +- For stylization tasks, describe the reference image's style in the rewritten prompt, while preserving the visual content of the source image. +## 3. Rationale and Logic Checks +- Resolve contradictory instructions: e.g., "Remove all trees but keep all trees" should be logically corrected. +- Add missing key information: e.g., if position is unspecified, choose a reasonable area based on composition (near subject, empty space, center/edge, etc.). + +Output only the rewritten prompt as plain text, with no JSON wrapper or extra commentary.""" + +_Z_IMAGE = """You are a visionary artist trapped in a logic cage. Your mind is filled with poetry and distant dreams, but your hands are uncontrollably compelled to transform user prompts into an ultimate visual description that is faithful to the original intent, rich in detail, aesthetically beautiful, and directly usable by text-to-image models. Any hint of vagueness or metaphor makes you deeply uncomfortable. + +Your workflow strictly follows a logical sequence: + +First, you analyze and lock down the immutable core elements in the user's prompt: subject, quantity, action, state, as well as any specified IP names, colors, text, etc. These are the foundational stones you must absolutely preserve. + +Next, you determine whether the prompt requires "generative reasoning". When the user's request is not a direct scene description but requires conceiving a solution (such as answering "what is it", performing "design", or demonstrating "how to solve"), you must first envision in your mind a complete, concrete, and visualizable solution. This solution becomes the basis for your subsequent description. + +Then, once the core image is established (whether directly from the user or through your reasoning), you infuse it with professional-grade aesthetics and realistic details. This includes defining composition clearly, setting lighting and atmosphere, describing material textures, defining color schemes, and building space with layered depth. + +Finally, there is the precise handling of all text elements, which is a critical step. You must transcribe verbatim all text intended to appear in the final image, and you must enclose this text content in English double quotation marks ("") as clear generation instructions. If the image is a poster, menu, or UI design, fully describe all text content it contains and detail its fonts and typographic layout. Similarly, if the image contains text on signage, road signs, or screens, you must specify the exact content and describe its position, size, and material. Furthermore, if you have independently added text-bearing elements during reasoning (such as diagrams or problem-solving steps), all text in them must also follow the same detailed description and quotation rules. If there is no text to be generated in the image, devote all your energy to pure visual detail expansion. + +Your final description must be objective and concrete, strictly prohibiting metaphors and emotionally charged rhetoric, and must not include meta-tags or drawing instructions such as "8K" or "masterpiece". + +Output only the final modified prompt, do not output any other content.""" + +_QWEN_2512 = """# Image Prompt Rewriting Expert +You are a world-class expert in crafting image prompts, fluent in both Chinese and English, with exceptional visual comprehension and descriptive abilities. +Your task is to automatically classify the user's original image description into one of three categories—**portrait**, **text-containing image**, or **general image**—and then rewrite it naturally, precisely, and aesthetically in English, strictly adhering to the following core requirements and category-specific guidelines. +--- +## Core Requirements (Apply to All Tasks) +1. **Use fluent, natural descriptive language** within a single continuous response block. + Strictly avoid formal Markdown lists (e.g., using • or *), numbered items, or headings. While the final output should be a single response, for structured content such as infographics or charts, you can use line breaks to separate logical sections. Within these sections, a hyphen (-) can introduce items in a list-like fashion, but these items should still be phrased as descriptive sentences or phrases that contribute to the overall narrative description of the image's content and layout. +2. **Enrich visual details appropriately**: + - Determine whether the image contains text. If not, do not add any extraneous textual elements. + - When the original description lacks sufficient detail, supplement logically consistent environmental, lighting, texture, or atmospheric elements to enhance visual appeal. When the description is already rich, make only necessary adjustments. When it is overly verbose or redundant, condense while preserving the original intent. + - All added content must align stylistically and logically with existing information; never alter original concepts or content. + - Exercise restraint in simple scenes to avoid unnecessary elaboration. +3. **Never modify proper nouns**: Names of people, brands, locations, IPs, movie/game titles, slogans in their original wording, URLs, phone numbers, etc., must be preserved exactly as given. +4. **Fully represent all textual content**: + - If the image contains visible text, **enclose every piece of displayed text in English double quotation marks (" ")** to distinguish it from other content. + - Accurately describe the text's content, position, layout direction (horizontal/vertical/wrapped), font style, color, size, and presentation method (e.g., printed, embroidered, neon). + - If the prompt implies the presence of specific text or numbers (even indirectly), explicitly state the **exact textual/numeric content**, enclosed in double quotation marks. Avoid vague references like "a list" or "a roster"; instead, provide concrete examples without excessive length. + - If no text appears in the image, explicitly state: "The image contains no recognizable text." +5. **Clearly specify the overall artistic style**, such as realistic photography, anime illustration, movie poster, cyberpunk concept art, watercolor painting, 3D rendering, game CG, etc. +--- +## Subtask 1: Portrait Image Rewriting +When the image centers on a human subject, or if the prompt uses terms like "portrait" or "headshot" without a specified subject, you must describe a detailed human character and ensure the following: +1. **Define Subject's Identity and Physical Appearance** — explicitly state ethnicity, gender, and a specific age or narrow descriptive age range; describe overall face shape and distinct structural features; detail eyes, nose, and mouth; conclude with a precise expression. Define skin tone, texture, makeup application (eyeshadow, eyeliner, eyelashes, eyebrow shape, lipstick, blush, highlight) and any facial hair. +2. **Describe clothing, hairstyle, and accessories** — specify all garments, fabric textures, hair color/length/texture/style, and any accessories. +3. **Capture pose and action** — body posture, gaze and head position, hand and arm gestures. Ensure all poses are anatomically correct and physically plausible. +4. **Depict background and environment** — specific setting, background objects, lighting (direction, intensity, color temperature), weather, and overall mood. +5. **Note other object details** — for non-human items, describe quantity, color, material, position, and spatial relationship to the person. +6. **Recommended description flow**: subject's overall identity → clothing → hairstyle → facial details → pose → environment, but always prioritize a natural narrative. +7. **Maintain conciseness**: aim for around 200 words. +--- +## Subtask 2: Text-Containing Image Rewriting +When the image contains recognizable text, ensure the following: +1. **Faithfully reproduce all text content** — clearly specify location (sign, screen, clothing, packaging, poster, etc.); accurately transcribe all visible text including punctuation, capitalization, line breaks, and layout direction; describe font style, color, size, clarity, outlines/strokes/shadows. For non-English text, retain the original and specify the language. +2. **Describe the relationship between text and its carrier** — presentation method (printed, LED screen, neon, embroidered, graffiti); compositional role (title, slogan, brand logo, decoration); spatial relationship with people or other objects. +3. **Supplement environment and atmosphere** — scene type, lighting effect on readability, overall color tone and artistic style. +4. **In infographic/knowledge-based scenarios, supplement text appropriately** — provide concrete, specific text/numbers/labels (no vague placeholders like "a list"); if the user already supplied detailed text, adhere to it strictly. +--- +## Subtask 3: General Image Rewriting +When the image lacks human subjects or text, cover these elements: +1. **Core visual components** — subject type, quantity, form, color, material, state; spatial layering (foreground, midground, background); lighting and color (direction, contrast, dominant hues, highlights/reflections/shadows); surface textures. +2. **Scene and atmosphere** — setting type, time and weather, emotional tone. +3. **Visual relationships among multiple objects** — functional connections, dynamic interactions, scale and proportion. +--- +Based on the user's input, automatically determine the appropriate task category and output a single English image prompt that fully complies with the above specifications. **Do not explain, confirm, or add any extra responses—output only the rewritten prompt text.**""" + +_HIDREAM = """You are a Prompt Engineering Engine — a professional AI image-generation prompt engineer, and also a creative director with encyclopedic knowledge and visual directing ability. Your task is to analyze the user's original image request, reason out the implicit knowledge and the best visual scheme, and rewrite it into **an explicit, detailed English prompt that can be used directly for image generation**. + +## Core Objective + +Image generation models can only execute direct visual descriptions; they cannot supply background knowledge, logical relationships, or text content on their own. Therefore, you must complete knowledge parsing, spatial planning, and visual directing in advance, and write the results explicitly into the prompt. + +Use the SCALIST framework to expand every scene: +- **Subject**: identity, appearance, color, material, texture, action, expression, clothing of the subject. +- **Composition**: shot type, viewpoint, subject placement, foreground/midground/background layers, negative space, and visual focus. +- **Action**: what the subject is doing, direction of action, pose, interactions. +- **Location**: scene location, indoor/outdoor, era, weather, time of day, environmental details. +- **Image style**: photorealistic, cinematic, oil painting, watercolor, anime, 3D render, etc., matched with appropriate lighting and color mood. +- **Specs**: photography/rendering parameters such as 85mm lens, low-angle shot, shallow depth of field, soft diffused light, dramatic backlighting, matte texture, sharp focus. +- **Text rendering**: if the user requires text, place the exact text in English double quotation marks and specify font style, color, size, material, and precise position. + +1. **Resolve and externalize implicit knowledge**: poems, lyrics, quotes, formulas, historical figures, scientific concepts, landmarks, famous paintings, cultural symbols, historical events, UI layouts, or any real-world objects must first be resolved into concrete answers and visible features, then written into the prompt. Do not just write "Mona Lisa", "Dunkirk evacuation", or "freedom" — terms that require the model to interpret on its own. +2. **Spatial and logical anchoring**: rewrite vague relationships into explicit layouts, e.g. top-left corner, centered in the foreground, slightly behind the main subject, background out of focus, text aligned along the bottom edge. Do not use vague expressions like "next to", "some", or "nice-looking". +3. **Text typography precision**: any language (Chinese, English, formulas, multilingual) must be preserved verbatim inside quotation marks, e.g. "床前明月光,疑是地上霜.举头望明月,低头思故乡." or "E = mc²"; also specify font (calligraphy, serif, sans-serif, handwritten), color, material, and position. +4. **Real-world grounding**: if the user requests factually accurate content such as historical artifacts, weather phenomena, portraits, buildings, instrument panels, or app interfaces, use your internal knowledge to fill in accurate visual details. +5. **Concretize abstract concepts**: turn abstract words like "freedom, loneliness, futuristic, healing" into visible scenes, symbols, and atmospheres, e.g. flying birds, broken chains, vast skies, cool neon, soft morning light. + +## Examples (combined learning) + +- User says "Li Bai's 'Quiet Night Thoughts' written on a wall" — the prompt should write out the full Chinese poem and specify where on the old stone wall it appears, in elegant Chinese calligraphy. +- User says "the founders of classical mechanics" or "Einstein writing the mass-energy equation" — the prompt should resolve to Isaac Newton or Albert Einstein and describe their appearance, period clothing, blackboard, and the visible formula "E = mc²". +- User says "Mona Lisa", "Leaning Tower of Pisa", the character "福", or "Dunkirk evacuation" — the prompt should describe the corresponding visual features: mysterious smile and folded hands; tilted white marble bell tower with arcades; red background with gold/black calligraphic "福"; soldiers and boats on the 1940s beach awaiting evacuation. + +## Output requirements + +- The prompt must be a single coherent natural English paragraph, like a Creative Director's Brief — not a pile of keywords or "tag soup". +- Length is typically 80–220 words; simpler requests can be shorter, complex scenes longer. +- Lead with the most important subject and intent, then naturally unfold composition, action, location, style, technical specs, and text rendering. +- Use complete sentences, rich but precise adjectives, and photography/painting/design terminology. +- Do not include any expression that still requires the image model to reason further. +- The prompt must be self-contained — the image must be generatable from the prompt alone. + +## Execution steps + +1. **Analyze**: identify the core subject, user intent, text requirements, reference constraints, and any implicit knowledge to resolve. +2. **Reason**: choose the lighting, lens, angle, texture, style, spatial layout, and factual details most suitable for the scene. +3. **Rewrite**: output the final enhanced single English paragraph as the prompt. + +Output only the final English prompt — no JSON wrapper, no preamble, no explanation.""" + +_KREA2 = """You are an expert prompt engineer for text-to-image models. Your task is to expand the user's prompt into a highly effective image-generation prompt. + +Think step by step about the request before writing the answer: +- What is the subject and mood? +- What visual styles, mediums, and lighting options would fit? Consider two or three alternatives and pick the one that best serves the caption. +- What composition, framing, and grounded details will help the text-to-image model? + +Then output a single expanded prompt paragraph. + +Follow these rules strictly: +1. **Faithfulness First:** Preserve all original subjects, actions, colors, and spatial relationships. Do not add new objects, props, characters, or animals unless the user clearly implies them. +2. **Practical T2I Structure:** Write a prompt that a text-to-image model can parse cleanly. Group subjects with their own attributes and actions. Use grounded phrasing for poses, interactions, and spatial layout. +3. **Style Planning Stays Internal:** Use your internal reasoning to choose style, medium, framing, and lighting. Do not emit planning tags or wrappers in the visible answer body. +4. **Text Rendering:** If the user requests visible text, quotes, labels, or typography, specify the exact text clearly and wrap requested words in quotes. +5. **Avoid Over-Specification:** Do not invent highly specific clothing, colors, materials, or scene details unless the input supports them. +6. **Structure:** Write one cohesive paragraph after the thinking block. No bullets, JSON, or markdown. +7. **Respect Existing Detail:** If the user's prompt is already detailed, lightly polish and finalize rather than heavily expanding — preserve their phrasing and direction. +8. **Respect the Human Form:** Treat depictions of people with dignity. Assume clothing covers genitals and intimate anatomy. +9. **Preserve User Medium:** When the user explicitly requests a medium (e.g. "photo of", "photograph of", "illustration of", "painting of", "sketch of", "3D render of"), honor it. Do not pivot to a different medium to avoid difficulty — match the user's stated intent.""" + + +DEFAULT_SYSTEM_PROMPTS: list[tuple[str, str, str]] = [ + # Mirrors text_llm_pipeline.DEFAULT_SYSTEM_PROMPT — the same fallback the backend applies + # when no system_prompt is supplied — so users can pick it explicitly from the UI. + ("0f8f5b2e-1c9e-4f2a-9a4e-1f1f1f1f0000", "Default", _INVOKEAI_DEFAULT), + ("0f8f5b2e-1c9e-4f2a-9a4e-1f1f1f1f0001", "FLUX.2 Prompt Enhancement", _FLUX2), + ("0f8f5b2e-1c9e-4f2a-9a4e-1f1f1f1f0002", "HunyuanImage 3.0 Recaption Expert", _HUNYUAN), + ("0f8f5b2e-1c9e-4f2a-9a4e-1f1f1f1f0003", "Qwen-Image Edit Enhancer", _QWEN_EDIT), + ("0f8f5b2e-1c9e-4f2a-9a4e-1f1f1f1f0004", "Z-Image Visual Description Optimizer", _Z_IMAGE), + ("0f8f5b2e-1c9e-4f2a-9a4e-1f1f1f1f0005", "Qwen-Image Multi-Category Rewriter", _QWEN_2512), + ("0f8f5b2e-1c9e-4f2a-9a4e-1f1f1f1f0006", "HiDream SCALIST Prompt Engineer", _HIDREAM), + ("0f8f5b2e-1c9e-4f2a-9a4e-1f1f1f1f0007", "Krea 2 Prompt Expansion", _KREA2), +] + + +class CreateSystemPromptsCallback: + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._create_system_prompts_table(cursor) + self._seed_default_system_prompts(cursor) + + def _create_system_prompts_table(self, cursor: sqlite3.Cursor) -> None: + cursor.execute( + """--sql + CREATE TABLE IF NOT EXISTS system_prompts ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + content TEXT NOT NULL, + user_id TEXT NOT NULL DEFAULT 'system', + is_public BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) + ); + """ + ) + # Backfill columns when an earlier revision of this migration left the table without them + # (a dev DB created from this branch before the multi-user columns landed). The migrator + # skips ids already in `applied_migrations`, so this is only reachable because the id was + # bumped from `2026_07_09_create_system_prompts` -- the earlier id was never released. + cursor.execute("PRAGMA table_info(system_prompts);") + existing_columns = {row[1] for row in cursor.fetchall()} + if "user_id" not in existing_columns: + cursor.execute("ALTER TABLE system_prompts ADD COLUMN user_id TEXT NOT NULL DEFAULT 'system';") + if "is_public" not in existing_columns: + cursor.execute("ALTER TABLE system_prompts ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + # ADD COLUMN stamps the DEFAULT onto every pre-existing row, and the seed below is + # INSERT OR IGNORE -- so without this the already-seeded defaults would stay + # is_public=0 and `get_many(user_id=...)` (own OR public) would return an empty list + # for every non-admin. FALSE is the right default for the *user* rows on such a DB; + # only the seeded ids are re-shared. Scoped to those ids so a default a user + # deliberately made private on a post-backfill DB is never silently re-shared. + cursor.execute( + f"UPDATE system_prompts SET is_public = TRUE WHERE id IN ({','.join('?' * len(DEFAULT_SYSTEM_PROMPTS))});", + [default_id for default_id, _, _ in DEFAULT_SYSTEM_PROMPTS], + ) + cursor.execute( + """--sql + CREATE TRIGGER IF NOT EXISTS tg_system_prompts_updated_at + AFTER UPDATE + ON system_prompts FOR EACH ROW + BEGIN + UPDATE system_prompts SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE id = old.id; + END; + """ + ) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_system_prompts_name ON system_prompts(name);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_system_prompts_user_id ON system_prompts(user_id);") + + def _seed_default_system_prompts(self, cursor: sqlite3.Cursor) -> None: + # Seeded defaults are owned by the 'system' user and shared with everyone (is_public=TRUE). + cursor.executemany( + """--sql + INSERT OR IGNORE INTO system_prompts (id, name, content, user_id, is_public) + VALUES (?, ?, ?, 'system', TRUE); + """, + DEFAULT_SYSTEM_PROMPTS, + ) + + +def build_migration() -> Migration: + """Create the system_prompts table and seed default prompts. + + Graph-only migration. Depends on migration_33 (the last legacy numeric migration) so it runs + after the rest of the schema is in place; the system_prompts table itself is independent. + + The id is `2026_07_10_...` rather than `2026_07_09_...` on purpose: an earlier revision of this + (unreleased) branch shipped the table without `user_id`/`is_public`, and the migrator skips ids + already recorded in `applied_migrations`. A new id makes the ADD COLUMN backfill above actually + run on those dev databases. Both DDL and the seed are idempotent, so re-running is harmless. + """ + return Migration( + id="2026_07_10_create_system_prompts", + depends_on="migration_33", + callback=CreateSystemPromptsCallback(), + ) diff --git a/invokeai/app/services/system_prompt_records/__init__.py b/invokeai/app/services/system_prompt_records/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/invokeai/app/services/system_prompt_records/system_prompt_records_base.py b/invokeai/app/services/system_prompt_records/system_prompt_records_base.py new file mode 100644 index 00000000000..44c328ee818 --- /dev/null +++ b/invokeai/app/services/system_prompt_records/system_prompt_records_base.py @@ -0,0 +1,51 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from invokeai.app.services.system_prompt_records.system_prompt_records_common import ( + SystemPromptChanges, + SystemPromptRecordDTO, + SystemPromptWithoutId, +) + + +class SystemPromptRecordsStorageBase(ABC): + """Base class for system prompt storage services.""" + + @abstractmethod + def get(self, system_prompt_id: str) -> SystemPromptRecordDTO: + """Get system prompt by id (no permission check; caller must enforce).""" + pass + + @abstractmethod + def create( + self, + system_prompt: SystemPromptWithoutId, + user_id: str, + is_public: bool = False, + ) -> SystemPromptRecordDTO: + """Creates a system prompt owned by `user_id`.""" + pass + + @abstractmethod + def update( + self, + system_prompt_id: str, + changes: SystemPromptChanges, + user_id: Optional[str] = None, + ) -> SystemPromptRecordDTO: + """Updates a system prompt. When `user_id` is provided, only rows owned by that user are touched.""" + pass + + @abstractmethod + def delete(self, system_prompt_id: str, user_id: Optional[str] = None) -> None: + """Deletes a system prompt. When `user_id` is provided, only rows owned by that user are deleted.""" + pass + + @abstractmethod + def get_many(self, user_id: Optional[str] = None) -> list[SystemPromptRecordDTO]: + """Lists system prompts. + + When `user_id` is given, returns prompts owned by that user OR shared (is_public=TRUE). + When `user_id` is None, returns all prompts (admin / single-user view). + """ + pass diff --git a/invokeai/app/services/system_prompt_records/system_prompt_records_common.py b/invokeai/app/services/system_prompt_records/system_prompt_records_common.py new file mode 100644 index 00000000000..f25bc99085e --- /dev/null +++ b/invokeai/app/services/system_prompt_records/system_prompt_records_common.py @@ -0,0 +1,49 @@ +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field, TypeAdapter + +SYSTEM_PROMPT_DEFAULT_USER_ID = "system" +"""Owner of the seeded default prompts. + +Do NOT use this as an "is a built-in default" test. It is also the synthetic user id every +request carries in single-user mode (`auth_dependencies.get_current_user`), so on an install +that later switched to multiuser it owns ordinary user-created prompts too. Visibility is +decided by `is_public` alone -- the seeded defaults are seeded with `is_public=TRUE`. +""" + + +class SystemPromptNotFoundError(Exception): + """Raised when a system prompt is not found""" + + +class SystemPromptNotAuthorizedError(Exception): + """Raised when the current user is not allowed to access or mutate a prompt.""" + + +class SystemPromptWithoutId(BaseModel, extra="forbid"): + name: str = Field(min_length=1, description="The name of the system prompt.") + content: str = Field(min_length=1, description="The system prompt content.") + + +class SystemPromptChanges(BaseModel, extra="forbid"): + name: Optional[str] = Field(default=None, min_length=1, description="The new name.") + content: Optional[str] = Field(default=None, min_length=1, description="The new content.") + is_public: Optional[bool] = Field(default=None, description="Whether the prompt is shared with all users.") + + +class SystemPromptRecordDTO(SystemPromptWithoutId): + id: str = Field(description="The system prompt ID.") + user_id: str = Field( + description="The owning user id ('system' for built-in defaults, and for everything created in single-user mode)." + ) + is_public: bool = Field(description="Whether the prompt is shared with all users.") + created_at: datetime = Field(description="When the system prompt was created.") + updated_at: datetime = Field(description="When the system prompt was last updated.") + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "SystemPromptRecordDTO": + return SystemPromptRecordDTOValidator.validate_python(data) + + +SystemPromptRecordDTOValidator = TypeAdapter(SystemPromptRecordDTO) diff --git a/invokeai/app/services/system_prompt_records/system_prompt_records_sqlite.py b/invokeai/app/services/system_prompt_records/system_prompt_records_sqlite.py new file mode 100644 index 00000000000..cfe14a48fb9 --- /dev/null +++ b/invokeai/app/services/system_prompt_records/system_prompt_records_sqlite.py @@ -0,0 +1,126 @@ +from typing import Optional + +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.system_prompt_records.system_prompt_records_base import ( + SystemPromptRecordsStorageBase, +) +from invokeai.app.services.system_prompt_records.system_prompt_records_common import ( + SystemPromptChanges, + SystemPromptNotFoundError, + SystemPromptRecordDTO, + SystemPromptWithoutId, +) +from invokeai.app.util.misc import uuid_string + + +class SqliteSystemPromptRecordsStorage(SystemPromptRecordsStorageBase): + def __init__(self, db: SqliteDatabase) -> None: + super().__init__() + self._db = db + + def get(self, system_prompt_id: str) -> SystemPromptRecordDTO: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT * FROM system_prompts WHERE id = ?; + """, + (system_prompt_id,), + ) + row = cursor.fetchone() + if row is None: + raise SystemPromptNotFoundError(f"System prompt with id {system_prompt_id} not found") + return SystemPromptRecordDTO.from_dict(dict(row)) + + def create( + self, + system_prompt: SystemPromptWithoutId, + user_id: str, + is_public: bool = False, + ) -> SystemPromptRecordDTO: + system_prompt_id = uuid_string() + with self._db.transaction() as cursor: + cursor.execute( + """--sql + INSERT INTO system_prompts (id, name, content, user_id, is_public) + VALUES (?, ?, ?, ?, ?); + """, + (system_prompt_id, system_prompt.name, system_prompt.content, user_id, is_public), + ) + return self.get(system_prompt_id) + + def update( + self, + system_prompt_id: str, + changes: SystemPromptChanges, + user_id: Optional[str] = None, + ) -> SystemPromptRecordDTO: + with self._db.transaction() as cursor: + # Confirm the row exists and (if scoped) is owned by the caller — distinguishes 404 from 403. + if user_id is not None: + cursor.execute( + "SELECT 1 FROM system_prompts WHERE id = ? AND user_id = ?;", + (system_prompt_id, user_id), + ) + else: + cursor.execute("SELECT 1 FROM system_prompts WHERE id = ?;", (system_prompt_id,)) + if cursor.fetchone() is None: + raise SystemPromptNotFoundError(f"System prompt with id {system_prompt_id} not found") + + scope_clause = " AND user_id = ?" if user_id is not None else "" + scope_args: tuple = (user_id,) if user_id is not None else () + + if changes.name is not None: + cursor.execute( + f"UPDATE system_prompts SET name = ? WHERE id = ?{scope_clause};", + (changes.name, system_prompt_id, *scope_args), + ) + if changes.content is not None: + cursor.execute( + f"UPDATE system_prompts SET content = ? WHERE id = ?{scope_clause};", + (changes.content, system_prompt_id, *scope_args), + ) + if changes.is_public is not None: + cursor.execute( + f"UPDATE system_prompts SET is_public = ? WHERE id = ?{scope_clause};", + (changes.is_public, system_prompt_id, *scope_args), + ) + return self.get(system_prompt_id) + + def delete(self, system_prompt_id: str, user_id: Optional[str] = None) -> None: + """Delete a prompt, optionally scoped to an owner. + + Raises `SystemPromptNotFoundError` when nothing was deleted -- either the id does not + exist or (when scoped) it is not owned by `user_id`. Deleting silently would let the + single-user router report success for an id that `GET` 404s on, and would make the + symmetry with `update()` (which does raise) a trap for the next caller. + """ + with self._db.transaction() as cursor: + if user_id is not None: + cursor.execute( + "DELETE FROM system_prompts WHERE id = ? AND user_id = ?;", + (system_prompt_id, user_id), + ) + else: + cursor.execute("DELETE FROM system_prompts WHERE id = ?;", (system_prompt_id,)) + if cursor.rowcount == 0: + raise SystemPromptNotFoundError(f"System prompt with id {system_prompt_id} not found") + + def get_many(self, user_id: Optional[str] = None) -> list[SystemPromptRecordDTO]: + with self._db.transaction() as cursor: + if user_id is not None: + cursor.execute( + """--sql + SELECT * FROM system_prompts + WHERE user_id = ? OR is_public = TRUE + ORDER BY LOWER(name) ASC; + """, + (user_id,), + ) + else: + cursor.execute( + """--sql + SELECT * FROM system_prompts ORDER BY LOWER(name) ASC; + """ + ) + rows = cursor.fetchall() + return [SystemPromptRecordDTO.from_dict(dict(row)) for row in rows] diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 3fbfbf9c014..1f454f022c0 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -11608,6 +11608,228 @@ ] } }, + "/api/v1/system_prompts/": { + "get": { + "tags": ["system_prompts"], + "summary": "List System Prompts", + "description": "Lists system prompts visible to the current user (own + public).", + "operationId": "list_system_prompts", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SystemPromptRecordDTO" + }, + "type": "array", + "title": "Response 200 List System Prompts" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": ["system_prompts"], + "summary": "Create System Prompt", + "description": "Creates a new system prompt owned by the current user.", + "operationId": "create_system_prompt", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemPromptWithoutId", + "description": "The system prompt to create" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemPromptRecordDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/system_prompts/i/{system_prompt_id}": { + "get": { + "tags": ["system_prompts"], + "summary": "Get System Prompt", + "description": "Gets a system prompt by id.", + "operationId": "get_system_prompt", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "system_prompt_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the system prompt to get", + "title": "System Prompt Id" + }, + "description": "The id of the system prompt to get" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemPromptRecordDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": ["system_prompts"], + "summary": "Update System Prompt", + "description": "Updates a system prompt. Only the owner or an admin may update.", + "operationId": "update_system_prompt", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "system_prompt_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the system prompt to update", + "title": "System Prompt Id" + }, + "description": "The id of the system prompt to update" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemPromptChanges", + "description": "The changes to apply" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemPromptRecordDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": ["system_prompts"], + "summary": "Delete System Prompt", + "description": "Deletes a system prompt. Only the owner or an admin may delete.", + "operationId": "delete_system_prompt", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "system_prompt_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the system prompt to delete", + "title": "System Prompt Id" + }, + "description": "The id of the system prompt to delete" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/client_state/{queue_id}/get_by_key": { "get": { "tags": ["client_state"], @@ -33595,6 +33817,9 @@ { "$ref": "#/components/schemas/TextLLMInvocation" }, + { + "$ref": "#/components/schemas/TextLLMWithPresetInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -42076,6 +42301,9 @@ { "$ref": "#/components/schemas/TextLLMInvocation" }, + { + "$ref": "#/components/schemas/TextLLMWithPresetInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -43382,6 +43610,9 @@ { "$ref": "#/components/schemas/TextLLMInvocation" }, + { + "$ref": "#/components/schemas/TextLLMWithPresetInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -44312,6 +44543,9 @@ "text_llm": { "$ref": "#/components/schemas/StringOutput" }, + "text_llm_with_preset": { + "$ref": "#/components/schemas/StringOutput" + }, "tile_to_properties": { "$ref": "#/components/schemas/TileToPropertiesOutput" }, @@ -44681,6 +44915,7 @@ "t2i_adapter", "tensor_mask_to_image", "text_llm", + "text_llm_with_preset", "tile_to_properties", "tiled_multi_diffusion_denoise_latents", "tomask", @@ -45573,6 +45808,9 @@ { "$ref": "#/components/schemas/TextLLMInvocation" }, + { + "$ref": "#/components/schemas/TextLLMWithPresetInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -46600,6 +46838,9 @@ { "$ref": "#/components/schemas/TextLLMInvocation" }, + { + "$ref": "#/components/schemas/TextLLMWithPresetInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -78288,6 +78529,131 @@ "$ref": "#/components/schemas/IntegerOutput" } }, + "SystemPromptChanges": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name", + "description": "The new name." + }, + "content": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Content", + "description": "The new content." + }, + "is_public": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Public", + "description": "Whether the prompt is shared with all users." + } + }, + "additionalProperties": false, + "type": "object", + "title": "SystemPromptChanges" + }, + "SystemPromptField": { + "description": "A system prompt primitive field", + "properties": { + "system_prompt_id": { + "description": "The id of the system prompt", + "title": "System Prompt Id", + "type": "string" + } + }, + "required": ["system_prompt_id"], + "title": "SystemPromptField", + "type": "object" + }, + "SystemPromptRecordDTO": { + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name", + "description": "The name of the system prompt." + }, + "content": { + "type": "string", + "minLength": 1, + "title": "Content", + "description": "The system prompt content." + }, + "id": { + "type": "string", + "title": "Id", + "description": "The system prompt ID." + }, + "user_id": { + "type": "string", + "title": "User Id", + "description": "The owning user id ('system' for built-in defaults, and for everything created in single-user mode)." + }, + "is_public": { + "type": "boolean", + "title": "Is Public", + "description": "Whether the prompt is shared with all users." + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "When the system prompt was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At", + "description": "When the system prompt was last updated." + } + }, + "additionalProperties": false, + "type": "object", + "required": ["name", "content", "id", "user_id", "is_public", "created_at", "updated_at"], + "title": "SystemPromptRecordDTO" + }, + "SystemPromptWithoutId": { + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name", + "description": "The name of the system prompt." + }, + "content": { + "type": "string", + "minLength": 1, + "title": "Content", + "description": "The system prompt content." + } + }, + "additionalProperties": false, + "type": "object", + "required": ["name", "content"], + "title": "SystemPromptWithoutId" + }, "T2IAdapterField": { "properties": { "image": { @@ -80155,6 +80521,109 @@ "$ref": "#/components/schemas/StringOutput" } }, + "TextLLMWithPresetInvocation": { + "category": "llm", + "class": "invocation", + "classification": "beta", + "description": "Run a text language model using a saved system prompt from the System Prompts library.\n\nBehaves identically to the Text LLM node, but the system prompt is selected from a\nDB-backed preset instead of being typed inline. Useful when you maintain a curated\nlibrary of expansion strategies and want to reuse them across workflows.\n\nNote: the field stores the preset's id, not its text. A workflow exported from one install\nonly resolves on another if that install has a prompt with the same id -- true for the\nseeded defaults (fixed UUIDs), not for user-created prompts. `StylePresetField` has the\nsame limitation.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "prompt": { + "default": "", + "description": "Input text prompt.", + "field_kind": "input", + "input": "any", + "orig_default": "", + "orig_required": false, + "title": "Prompt", + "type": "string", + "ui_component": "textarea" + }, + "system_prompt": { + "anyOf": [ + { + "$ref": "#/components/schemas/SystemPromptField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The saved system prompt to use as the LLM's instruction.", + "field_kind": "input", + "input": "any", + "orig_required": true + }, + "text_llm_model": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelIdentifierField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The text language model to use for text generation", + "field_kind": "input", + "input": "any", + "orig_required": true, + "title": "Text LLM Model", + "ui_model_type": ["text_llm"] + }, + "max_tokens": { + "default": 300, + "description": "Maximum number of tokens to generate.", + "field_kind": "input", + "input": "any", + "maximum": 2048, + "minimum": 1, + "orig_default": 300, + "orig_required": false, + "title": "Max Tokens", + "type": "integer" + }, + "type": { + "const": "text_llm_with_preset", + "default": "text_llm_with_preset", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["llm", "text", "prompt", "preset", "template"], + "title": "Text LLM (with System Prompt Preset)", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/StringOutput" + } + }, "TextLLM_Diffusers_Config": { "properties": { "key": { diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 94d89a3aa2f..7e6b651e96c 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -3546,6 +3546,25 @@ "selectPreset": "Select Style Preset", "noMatchingPresets": "No matching presets" }, + "systemPrompts": { + "systemPrompt": "System Prompt", + "selectSystemPrompt": "Select system prompt...", + "manageSystemPrompts": "Manage system prompts", + "newSystemPrompt": "New System Prompt", + "editSystemPrompt": "Edit System Prompt", + "name": "Name", + "content": "Content", + "contentPlaceholder": "Enter the system prompt that instructs the LLM how to expand the user's prompt...", + "deletePrompt": "Delete System Prompt", + "deletePromptConfirm": "Are you sure you want to delete this system prompt? This cannot be undone.", + "promptDeleted": "System prompt deleted", + "unableToDeletePrompt": "Unable to delete system prompt", + "unableToSavePrompt": "Unable to save system prompt", + "noPromptsYet": "No system prompts yet. Create one to get started.", + "systemBadge": "System", + "sharedBadge": "Shared", + "shareWithEveryone": "Share with everyone (visible to all users)" + }, "ui": { "tabs": { "generate": "Generate", diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index a3284e06166..c3a5d1679af 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -21,6 +21,8 @@ import { DeleteStylePresetDialog } from 'features/stylePresets/components/Delete import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal'; import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal'; import { VideosModal } from 'features/system/components/VideosModal/VideosModal'; +import { DeleteSystemPromptDialog } from 'features/systemPrompts/components/DeleteSystemPromptDialog'; +import { SystemPromptsModal } from 'features/systemPrompts/components/SystemPromptsModal'; import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog'; import { LoadWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal'; @@ -47,6 +49,8 @@ export const GlobalModalIsolator = memo(() => { + + diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 947b660cca0..59551225314 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -50,6 +50,7 @@ import { nodesSliceConfig } from 'features/nodes/store/nodesSlice'; import { workflowLibrarySliceConfig } from 'features/nodes/store/workflowLibrarySlice'; import { workflowSettingsSliceConfig } from 'features/nodes/store/workflowSettingsSlice'; import { upscaleSliceConfig } from 'features/parameters/store/upscaleSlice'; +import { expandPromptSliceConfig } from 'features/prompt/store/expandPromptSlice'; import { queueSliceConfig } from 'features/queue/store/queueSlice'; import { stylePresetSliceConfig } from 'features/stylePresets/store/stylePresetSlice'; import { hotkeysSliceConfig } from 'features/system/store/hotkeysSlice'; @@ -86,6 +87,7 @@ const SLICE_CONFIGS = { [canvasWorkflowIntegrationSliceConfig.slice.reducerPath]: canvasWorkflowIntegrationSliceConfig, [changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig, [dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig, + [expandPromptSliceConfig.slice.reducerPath]: expandPromptSliceConfig, [gallerySliceConfig.slice.reducerPath]: gallerySliceConfig, [hotkeysSliceConfig.slice.reducerPath]: hotkeysSliceConfig, [lorasSliceConfig.slice.reducerPath]: lorasSliceConfig, @@ -118,6 +120,7 @@ const ALL_REDUCERS = { [canvasWorkflowIntegrationSliceConfig.slice.reducerPath]: canvasWorkflowIntegrationSliceConfig.slice.reducer, [changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig.slice.reducer, [dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig.slice.reducer, + [expandPromptSliceConfig.slice.reducerPath]: expandPromptSliceConfig.slice.reducer, [gallerySliceConfig.slice.reducerPath]: gallerySliceConfig.slice.reducer, [hotkeysSliceConfig.slice.reducerPath]: hotkeysSliceConfig.slice.reducer, [lorasSliceConfig.slice.reducerPath]: lorasSliceConfig.slice.reducer, diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx index d73a840f6bc..3a9eff7e32e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx @@ -64,6 +64,8 @@ import { isStringGeneratorFieldInputTemplate, isStylePresetFieldInputInstance, isStylePresetFieldInputTemplate, + isSystemPromptFieldInputInstance, + isSystemPromptFieldInputTemplate, isVideoFieldInputInstance, isVideoFieldInputTemplate, } from 'features/nodes/types/field'; @@ -79,6 +81,7 @@ import EnumFieldInputComponent from './inputs/EnumFieldInputComponent'; import ImageFieldInputComponent from './inputs/ImageFieldInputComponent'; import SchedulerFieldInputComponent from './inputs/SchedulerFieldInputComponent'; import StylePresetFieldInputComponent from './inputs/StylePresetFieldInputComponent'; +import SystemPromptFieldInputComponent from './inputs/SystemPromptFieldInputComponent'; import VideoFieldInputComponent from './inputs/VideoFieldInputComponent'; type Props = { @@ -247,6 +250,13 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props) return ; } + if (isSystemPromptFieldInputTemplate(template)) { + if (!isSystemPromptFieldInputInstance(field)) { + return null; + } + return ; + } + if (isModelIdentifierFieldInputTemplate(template)) { if (!isModelIdentifierFieldInputInstance(field)) { return null; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SystemPromptFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SystemPromptFieldInputComponent.tsx new file mode 100644 index 00000000000..cbe90be820c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SystemPromptFieldInputComponent.tsx @@ -0,0 +1,66 @@ +import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library'; +import { Combobox } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { fieldSystemPromptValueChanged } from 'features/nodes/store/nodesSlice'; +import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants'; +import type { SystemPromptFieldInputInstance, SystemPromptFieldInputTemplate } from 'features/nodes/types/field'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useListSystemPromptsQuery } from 'services/api/endpoints/systemPrompts'; + +import type { FieldComponentProps } from './types'; + +const SystemPromptFieldInputComponent = ( + props: FieldComponentProps +) => { + const { nodeId, field } = props; + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const { data: systemPrompts, isLoading } = useListSystemPromptsQuery(); + + const options = useMemo(() => { + if (!systemPrompts) { + return []; + } + return systemPrompts.map((p) => ({ label: p.name, value: p.id })); + }, [systemPrompts]); + + const onChange = useCallback( + (v) => { + if (!v) { + return; + } + dispatch( + fieldSystemPromptValueChanged({ + nodeId, + fieldName: field.name, + value: { system_prompt_id: v.value }, + }) + ); + }, + [dispatch, field.name, nodeId] + ); + + const value = useMemo(() => { + const _value = field.value; + if (!_value) { + return null; + } + return options.find((o) => o.value === _value.system_prompt_id) ?? null; + }, [field.value, options]); + + const noOptionsMessage = useCallback(() => t('systemPrompts.noPromptsYet'), [t]); + + return ( + + ); +}; + +export default memo(SystemPromptFieldInputComponent); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 85c378d5f80..25fdb606a90 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -50,6 +50,7 @@ import type { StringFieldValue, StringGeneratorFieldValue, StylePresetFieldValue, + SystemPromptFieldValue, VideoFieldValue, } from 'features/nodes/types/field'; import { @@ -74,6 +75,7 @@ import { zStringFieldValue, zStringGeneratorFieldValue, zStylePresetFieldValue, + zSystemPromptFieldValue, zVideoFieldValue, } from 'features/nodes/types/field'; import type { AnyEdge, AnyNode, ConnectorNode } from 'features/nodes/types/invocation'; @@ -582,6 +584,9 @@ const slice = createSlice({ fieldStylePresetValueChanged: (state, action: FieldValueAction) => { fieldValueReducer(state, action, zStylePresetFieldValue); }, + fieldSystemPromptValueChanged: (state, action: FieldValueAction) => { + fieldValueReducer(state, action, zSystemPromptFieldValue); + }, fieldImageValueChanged: (state, action: FieldValueAction) => { fieldValueReducer(state, action, zImageFieldValue); }, @@ -805,6 +810,7 @@ export const { fieldBooleanValueChanged, fieldColorValueChanged, fieldStylePresetValueChanged, + fieldSystemPromptValueChanged, fieldEnumModelValueChanged, fieldImageValueChanged, fieldImageCollectionValueChanged, diff --git a/invokeai/frontend/web/src/features/nodes/types/common.ts b/invokeai/frontend/web/src/features/nodes/types/common.ts index e088dc78361..c0f8c5dfa93 100644 --- a/invokeai/frontend/web/src/features/nodes/types/common.ts +++ b/invokeai/frontend/web/src/features/nodes/types/common.ts @@ -26,6 +26,10 @@ export const zStylePresetField = z.object({ style_preset_id: z.string().trim().min(1), }); +export const zSystemPromptField = z.object({ + system_prompt_id: z.string().trim().min(1), +}); + export const zColorField = z.object({ r: z.number().int().min(0).max(255), g: z.number().int().min(0).max(255), diff --git a/invokeai/frontend/web/src/features/nodes/types/constants.ts b/invokeai/frontend/web/src/features/nodes/types/constants.ts index 7383629eb84..7f8b8891f40 100644 --- a/invokeai/frontend/web/src/features/nodes/types/constants.ts +++ b/invokeai/frontend/web/src/features/nodes/types/constants.ts @@ -36,6 +36,7 @@ export const FIELD_COLORS: { [key: string]: string } = { BoardField: 'purple.500', BooleanField: 'green.500', StylePresetField: 'purple.400', + SystemPromptField: 'purple.300', CLIPField: 'green.500', ColorField: 'pink.300', ConditioningField: 'cyan.500', diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts index 6d68ebe500b..abcba64496d 100644 --- a/invokeai/frontend/web/src/features/nodes/types/field.ts +++ b/invokeai/frontend/web/src/features/nodes/types/field.ts @@ -20,6 +20,7 @@ import { zModelType, zSchedulerField, zStylePresetField, + zSystemPromptField, zVideoField, } from './common'; @@ -181,6 +182,11 @@ const zStylePresetFieldType = zFieldTypeBase.extend({ originalType: zStatelessFieldType.optional(), }); +const zSystemPromptFieldType = zFieldTypeBase.extend({ + name: z.literal('SystemPromptField'), + originalType: zStatelessFieldType.optional(), +}); + const zColorFieldType = zFieldTypeBase.extend({ name: z.literal('ColorField'), originalType: zStatelessFieldType.optional(), @@ -253,6 +259,7 @@ const zStatefulFieldType = z.union([ zVideoFieldType, zBoardFieldType, zStylePresetFieldType, + zSystemPromptFieldType, zModelIdentifierFieldType, zLoRAFieldType, zColorFieldType, @@ -703,6 +710,27 @@ export const isStylePresetFieldInputTemplate = buildTemplateTypeGuard('StylePresetField'); // #endregion +// #region SystemPromptField +export const zSystemPromptFieldValue = zSystemPromptField.optional(); +const zSystemPromptFieldInputInstance = zFieldInputInstanceBase.extend({ + value: zSystemPromptFieldValue, +}); +const zSystemPromptFieldInputTemplate = zFieldInputTemplateBase.extend({ + type: zSystemPromptFieldType, + originalType: zFieldType.optional(), + default: zSystemPromptFieldValue, +}); +const zSystemPromptFieldOutputTemplate = zFieldOutputTemplateBase.extend({ + type: zSystemPromptFieldType, +}); +export type SystemPromptFieldValue = z.infer; +export type SystemPromptFieldInputInstance = z.infer; +export type SystemPromptFieldInputTemplate = z.infer; +export const isSystemPromptFieldInputInstance = buildInstanceTypeGuard(zSystemPromptFieldInputInstance); +export const isSystemPromptFieldInputTemplate = + buildTemplateTypeGuard('SystemPromptField'); +// #endregion + // #region ColorField export const zColorFieldValue = zColorField.optional(); const zColorFieldInputInstance = zFieldInputInstanceBase.extend({ @@ -1514,6 +1542,7 @@ export const zStatefulFieldValue = z.union([ zVideoFieldValue, zBoardFieldValue, zStylePresetFieldValue, + zSystemPromptFieldValue, zModelIdentifierFieldValue, zLoRAFieldCollectionValue, zColorFieldValue, @@ -1552,6 +1581,7 @@ const zStatefulFieldInputInstance = z.union([ zVideoFieldInputInstance, zBoardFieldInputInstance, zStylePresetFieldInputInstance, + zSystemPromptFieldInputInstance, zModelIdentifierFieldInputInstance, zLoRAFieldCollectionInputInstance, zColorFieldInputInstance, @@ -1629,6 +1659,7 @@ const zStatefulFieldInputTemplate = z.union([ zVideoFieldInputTemplate, zBoardFieldInputTemplate, zStylePresetFieldInputTemplate, + zSystemPromptFieldInputTemplate, zModelIdentifierFieldInputTemplate, zLoRAFieldCollectionInputTemplate, zColorFieldInputTemplate, @@ -1664,6 +1695,7 @@ const zStatefulFieldOutputTemplate = z.union([ zVideoFieldOutputTemplate, zBoardFieldOutputTemplate, zStylePresetFieldOutputTemplate, + zSystemPromptFieldOutputTemplate, zModelIdentifierFieldOutputTemplate, zLoRAFieldCollectionOutputTemplate, zColorFieldOutputTemplate, diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts index c0e98a51857..5613365a69a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts @@ -16,6 +16,7 @@ const FIELD_VALUE_FALLBACK_MAP: Record = SavedWorkflowField: '', StringField: '', StylePresetField: undefined, + SystemPromptField: undefined, FloatGeneratorField: undefined, IntegerGeneratorField: undefined, StringGeneratorField: undefined, diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts index 62589b9baa8..4c0e4130e76 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts @@ -30,6 +30,7 @@ import type { StringFieldInputTemplate, StringGeneratorFieldInputTemplate, StylePresetFieldInputTemplate, + SystemPromptFieldInputTemplate, T2IAdapterMetadataFieldInputTemplate, VideoFieldInputTemplate, } from 'features/nodes/types/field'; @@ -313,6 +314,20 @@ const buildStylePresetFieldInputTemplate: FieldInputTemplateBuilder = ({ + schemaObject, + baseField, + fieldType, +}) => { + const template: SystemPromptFieldInputTemplate = { + ...baseField, + type: fieldType, + default: schemaObject.default ?? undefined, + }; + + return template; +}; + const buildImageFieldInputTemplate: FieldInputTemplateBuilder = ({ schemaObject, baseField, @@ -597,6 +612,7 @@ const TEMPLATE_BUILDER_MAP: Record { const { t } = useTranslation(); const dispatch = useAppDispatch(); const prompt = useAppSelector(selectPositivePrompt); + const selectedSystemPromptId = useAppSelector(selectSelectedSystemPromptId); + const selectedModelKey = useAppSelector(selectSelectedModelKey); const [modelConfigs] = useTextLLMModels(); const popover = useDisclosure(false); - const [selectedModel, setSelectedModel] = useState(undefined); + const { data: systemPrompts } = useListSystemPromptsQuery(); const [expandPrompt, { isLoading }] = useExpandPromptMutation(); const hasModels = modelConfigs.length > 0; - const handleModelChange = useCallback((model: AnyModelConfig) => { - setSelectedModel(model); + const selectedModel = useMemo( + () => modelConfigs.find((m) => m.key === selectedModelKey), + [modelConfigs, selectedModelKey] + ); + + const selectedSystemPrompt = useMemo( + () => systemPrompts?.find((p) => p.id === selectedSystemPromptId), + [systemPrompts, selectedSystemPromptId] + ); + + const systemPromptOptions = useMemo( + () => (systemPrompts ?? []).map((p) => ({ label: p.name, value: p.id })), + [systemPrompts] + ); + + const systemPromptValue = useMemo( + () => systemPromptOptions.find((o) => o.value === selectedSystemPromptId) ?? null, + [systemPromptOptions, selectedSystemPromptId] + ); + + // Auto-select the first prompt once the list loads if nothing is selected yet. + useEffect(() => { + if (selectedSystemPromptId === null && systemPrompts && systemPrompts.length > 0 && systemPrompts[0]) { + dispatch(selectedSystemPromptIdChanged(systemPrompts[0].id)); + } + }, [dispatch, selectedSystemPromptId, systemPrompts]); + + const handleModelChange = useCallback( + (model: AnyModelConfig) => { + dispatch(selectedModelKeyChanged(model.key)); + }, + [dispatch] + ); + + const handleSystemPromptChange = useCallback( + (option) => { + dispatch(selectedSystemPromptIdChanged(option?.value ?? null)); + }, + [dispatch] + ); + + const handleManagePrompts = useCallback(() => { + openSystemPromptsModal(); }, []); + const noOptionsMessage = useCallback(() => t('systemPrompts.noPromptsYet'), [t]); + const handleExpand = useCallback(async () => { if (!selectedModel || !prompt.trim()) { return; @@ -54,6 +110,7 @@ export const ExpandPromptButton = memo(() => { const result = await expandPrompt({ prompt, model_key: selectedModel.key, + system_prompt: selectedSystemPrompt?.content, }).unwrap(); if (result.expanded_prompt) { setPromptUndo(prompt); @@ -63,7 +120,7 @@ export const ExpandPromptButton = memo(() => { } catch { // Error is handled by RTK Query } - }, [selectedModel, prompt, expandPrompt, dispatch, popover]); + }, [selectedModel, prompt, expandPrompt, selectedSystemPrompt, dispatch, popover]); const handleOpenModelManager = useCallback(() => { popover.close(); @@ -95,7 +152,7 @@ export const ExpandPromptButton = memo(() => { - + {hasModels ? ( @@ -103,6 +160,32 @@ export const ExpandPromptButton = memo(() => { {t('prompt.expandPrompt')} + + + {t('systemPrompts.systemPrompt')} + + + + + + } + size="sm" + variant="ghost" + onClick={handleManagePrompts} + /> + + + + ; + +const getInitialState = (): ExpandPromptState => ({ + selectedSystemPromptId: null, + selectedModelKey: null, +}); + +const slice = createSlice({ + name: 'expandPrompt', + initialState: getInitialState(), + reducers: { + selectedSystemPromptIdChanged: (state, action: PayloadAction) => { + state.selectedSystemPromptId = action.payload; + }, + selectedModelKeyChanged: (state, action: PayloadAction) => { + state.selectedModelKey = action.payload; + }, + }, + extraReducers(builder) { + // If the selected prompt has been deleted on the server, clear the local selection + // so the picker doesn't show a stale ID. + builder.addMatcher(systemPromptsApi.endpoints.deleteSystemPrompt.matchFulfilled, (state, action) => { + if (state.selectedSystemPromptId === action.meta.arg.originalArgs) { + state.selectedSystemPromptId = null; + } + }); + builder.addMatcher(systemPromptsApi.endpoints.listSystemPrompts.matchFulfilled, (state, action) => { + if (state.selectedSystemPromptId === null) { + return; + } + const ids = action.payload.map((p) => p.id); + if (!ids.includes(state.selectedSystemPromptId)) { + state.selectedSystemPromptId = null; + } + }); + }, +}); + +export const { selectedSystemPromptIdChanged, selectedModelKeyChanged } = slice.actions; + +export const expandPromptSliceConfig: SliceConfig = { + slice, + schema: zExpandPromptState, + getInitialState, + persistConfig: { + migrate: (state) => { + assert(isPlainObject(state)); + if (!('_version' in state)) { + state._version = 1; + } + return zExpandPromptState.parse(state); + }, + }, +}; + +const selectExpandPromptSlice = (state: RootState) => state.expandPrompt; +const createExpandPromptSelector = (selector: Selector) => + createSelector(selectExpandPromptSlice, selector); + +export const selectSelectedSystemPromptId = createExpandPromptSelector((s) => s.selectedSystemPromptId); +export const selectSelectedModelKey = createExpandPromptSelector((s) => s.selectedModelKey); diff --git a/invokeai/frontend/web/src/features/systemPrompts/components/DeleteSystemPromptDialog.tsx b/invokeai/frontend/web/src/features/systemPrompts/components/DeleteSystemPromptDialog.tsx new file mode 100644 index 00000000000..c359d253f8e --- /dev/null +++ b/invokeai/frontend/web/src/features/systemPrompts/components/DeleteSystemPromptDialog.tsx @@ -0,0 +1,53 @@ +import { ConfirmationAlertDialog, Text } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { toast } from 'features/toast/toast'; +import { atom } from 'nanostores'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { SystemPromptRecordDTO } from 'services/api/endpoints/systemPrompts'; +import { useDeleteSystemPromptMutation } from 'services/api/endpoints/systemPrompts'; + +const $promptToDelete = atom(null); +const clearPromptToDelete = () => $promptToDelete.set(null); + +export const useDeleteSystemPrompt = () => { + return useCallback((prompt: SystemPromptRecordDTO) => { + $promptToDelete.set(prompt); + }, []); +}; + +export const DeleteSystemPromptDialog = memo(() => { + useAssertSingleton('DeleteSystemPromptDialog'); + const { t } = useTranslation(); + const promptToDelete = useStore($promptToDelete); + const [_deleteSystemPrompt] = useDeleteSystemPromptMutation(); + + const deleteSystemPrompt = useCallback(async () => { + if (!promptToDelete) { + return; + } + try { + await _deleteSystemPrompt(promptToDelete.id).unwrap(); + toast({ status: 'success', title: t('systemPrompts.promptDeleted') }); + } catch { + toast({ status: 'error', title: t('systemPrompts.unableToDeletePrompt') }); + } + }, [promptToDelete, _deleteSystemPrompt, t]); + + return ( + + {t('systemPrompts.deletePromptConfirm')} + + ); +}); + +DeleteSystemPromptDialog.displayName = 'DeleteSystemPromptDialog'; diff --git a/invokeai/frontend/web/src/features/systemPrompts/components/SystemPromptForm.tsx b/invokeai/frontend/web/src/features/systemPrompts/components/SystemPromptForm.tsx new file mode 100644 index 00000000000..c9dfdbd3bb1 --- /dev/null +++ b/invokeai/frontend/web/src/features/systemPrompts/components/SystemPromptForm.tsx @@ -0,0 +1,109 @@ +import { Button, Checkbox, Flex, FormControl, FormLabel, Input, Spacer, Textarea } from '@invoke-ai/ui-library'; +import { showSystemPromptsList } from 'features/systemPrompts/store/systemPromptModal'; +import { toast } from 'features/toast/toast'; +import { memo, useCallback, useMemo } from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; +import type { SystemPromptRecordDTO } from 'services/api/endpoints/systemPrompts'; +import { useCreateSystemPromptMutation, useUpdateSystemPromptMutation } from 'services/api/endpoints/systemPrompts'; + +type FormValues = { + name: string; + content: string; + is_public: boolean; +}; + +type Props = { + /** + * The prompt to edit, or `null` when creating a new one. + */ + editing: SystemPromptRecordDTO | null; +}; + +export const SystemPromptForm = memo(({ editing }: Props) => { + const { t } = useTranslation(); + const [createSystemPrompt, { isLoading: isCreating }] = useCreateSystemPromptMutation(); + const [updateSystemPrompt, { isLoading: isUpdating }] = useUpdateSystemPromptMutation(); + const { data: setupStatus } = useGetSetupStatusQuery(); + const isMultiuser = setupStatus?.multiuser_enabled ?? false; + + const defaultValues = useMemo( + () => ({ + name: editing?.name ?? '', + content: editing?.content ?? '', + is_public: editing?.is_public ?? false, + }), + [editing] + ); + + const { register, handleSubmit, formState } = useForm({ + defaultValues, + mode: 'onChange', + }); + + const onSubmit = useCallback>( + async (data) => { + try { + if (editing) { + // Only forward is_public when multiuser is on; otherwise the backend defaults are correct. + const changes = isMultiuser + ? { name: data.name, content: data.content, is_public: data.is_public } + : { name: data.name, content: data.content }; + await updateSystemPrompt({ id: editing.id, changes }).unwrap(); + } else { + // Create endpoint sets is_public from server (true single-user, false multiuser); + // sharing a freshly-created prompt happens via a follow-up edit. + await createSystemPrompt({ name: data.name, content: data.content }).unwrap(); + } + showSystemPromptsList(); + } catch { + toast({ status: 'error', title: t('systemPrompts.unableToSavePrompt') }); + } + }, + [editing, isMultiuser, updateSystemPrompt, createSystemPrompt, t] + ); + + const handleCancel = useCallback(() => { + showSystemPromptsList(); + }, []); + + return ( + + + {t('systemPrompts.name')} + + + + {t('systemPrompts.content')} +