Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
21cf868
feat: add System Prompts library for Expand Prompt button
Pfannkuchensack May 10, 2026
a6aa6c2
feat(system-prompts): scope CRUD to owner/admin for multi-user installs
Pfannkuchensack May 10, 2026
82ac6ef
Add Default System Prompt as DB row
Pfannkuchensack May 10, 2026
1a4f1cc
fix(system-prompts): unbreak migration import + cover ownership in tests
Pfannkuchensack May 10, 2026
9887d13
Chores Ruff + typegen
Pfannkuchensack May 10, 2026
70219ef
test(system-prompts): wire system_prompt_records in multiuser_authori…
Pfannkuchensack May 10, 2026
873d493
feat(system-prompts): add Text LLM (with System Prompt Preset) workfl…
Pfannkuchensack May 10, 2026
da6a90a
Chore Ruff + Typegen
Pfannkuchensack May 10, 2026
e187c04
Merge remote-tracking branch 'upstream/main' into feat/system-prompts…
Pfannkuchensack May 12, 2026
caf4553
Merge branch 'main' into feat/system-prompts-library
Pfannkuchensack May 16, 2026
578d1c4
Merge branch 'main' into feat/system-prompts-library
Pfannkuchensack May 22, 2026
bc59d96
chore: regenerate openapi schema for system prompts endpoints
Pfannkuchensack May 22, 2026
93f447a
Chore fix Path
Pfannkuchensack May 22, 2026
35cd529
Merge remote-tracking branch 'upstream/main' into feat/system-prompts…
Pfannkuchensack Jun 5, 2026
57dfce6
Merge branch 'main' into feat/system-prompts-library
Pfannkuchensack Jun 12, 2026
f8bd865
Merge remote-tracking branch 'upstream/main' into feat/system-prompts…
Pfannkuchensack Jul 9, 2026
9726ff5
test: pass system_prompt_records to InvocationServices in merged-in t…
Pfannkuchensack Jul 9, 2026
f082795
Merge branch 'main' into feat/system-prompts-library
Pfannkuchensack Jul 11, 2026
8dd8c1e
Merge remote-tracking branch 'upstream/main' into feat/system-prompts…
Pfannkuchensack Jul 28, 2026
b0a4d36
fix(tests): add missing video/gallery services to system prompts test…
lstein Jul 29, 2026
2dc4432
fix(system-prompts): address review feedback on #9152
Pfannkuchensack Jul 29, 2026
3811286
Merge remote-tracking branch 'upstream/main' into feat/system-prompts…
Pfannkuchensack Jul 29, 2026
1d2d3fe
Merge branch 'main' into feat/system-prompts-library
Pfannkuchensack Jul 29, 2026
375733a
feat(system-prompts): add Krea 2 expansion prompt, fix node/backfill …
Pfannkuchensack Jul 30, 2026
c375717
Merge remote-tracking branch 'upstream/main' into feat/system-prompts…
Pfannkuchensack Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions invokeai/app/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
120 changes: 120 additions & 0 deletions invokeai/app/api/routers/system_prompts.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions invokeai/app/api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
recall_parameters,
session_queue,
style_presets,
system_prompts,
utilities,
videos,
virtual_boards,
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions invokeai/app/invocations/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down
140 changes: 124 additions & 16 deletions invokeai/app/invocations/text_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
3 changes: 3 additions & 0 deletions invokeai/app/services/invocation_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading