appkit-assistant 0.7.8__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/PKG-INFO +1 -1
  2. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/pyproject.toml +1 -1
  3. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/models.py +45 -1
  4. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/processors/openai_responses_processor.py +5 -4
  5. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/repositories.py +88 -1
  6. appkit_assistant-0.9.0/src/appkit_assistant/backend/system_prompt_cache.py +161 -0
  7. appkit_assistant-0.9.0/src/appkit_assistant/components/system_prompt_editor.py +78 -0
  8. appkit_assistant-0.9.0/src/appkit_assistant/state/system_prompt_state.py +181 -0
  9. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/.gitignore +0 -0
  10. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/README.md +0 -0
  11. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/docs/assistant.png +0 -0
  12. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/model_manager.py +0 -0
  13. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/processor.py +0 -0
  14. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/processors/ai_models.py +0 -0
  15. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/processors/knowledgeai_processor.py +0 -0
  16. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/processors/lorem_ipsum_processor.py +0 -0
  17. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/processors/openai_base.py +0 -0
  18. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/processors/openai_chat_completion_processor.py +0 -0
  19. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/processors/perplexity_processor.py +0 -0
  20. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/backend/system_prompt.py +0 -0
  21. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/components/__init__.py +0 -0
  22. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/components/composer.py +0 -0
  23. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/components/composer_key_handler.py +0 -0
  24. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/components/mcp_server_dialogs.py +0 -0
  25. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/components/mcp_server_table.py +0 -0
  26. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/components/message.py +0 -0
  27. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/components/thread.py +0 -0
  28. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/components/threadlist.py +0 -0
  29. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/components/tools_modal.py +0 -0
  30. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/configuration.py +0 -0
  31. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/state/mcp_server_state.py +0 -0
  32. {appkit_assistant-0.7.8 → appkit_assistant-0.9.0}/src/appkit_assistant/state/thread_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appkit-assistant
3
- Version: 0.7.8
3
+ Version: 0.9.0
4
4
  Summary: Add your description here
5
5
  Project-URL: Homepage, https://github.com/jenreh/appkit
6
6
  Project-URL: Documentation, https://github.com/jenreh/appkit/tree/main/docs
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "appkit-assistant"
3
- version = "0.7.8"
3
+ version = "0.9.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Jens Rehpöhler" }]
@@ -1,8 +1,9 @@
1
+ from datetime import datetime
1
2
  from enum import StrEnum
2
3
 
3
4
  import reflex as rx
4
5
  from pydantic import BaseModel
5
- from sqlmodel import Field
6
+ from sqlmodel import Column, DateTime, Field
6
7
 
7
8
  from appkit_commons.database.entities import EncryptedString
8
9
 
@@ -92,6 +93,14 @@ class ThreadModel(BaseModel):
92
93
  ai_model: str = ""
93
94
 
94
95
 
96
+ class MCPAuthType(StrEnum):
97
+ """Enum for MCP server authentication types."""
98
+
99
+ NONE = "none"
100
+ API_KEY = "api_key"
101
+ OAUTH_DISCOVERY = "oauth_discovery"
102
+
103
+
95
104
  class MCPServer(rx.Model, table=True):
96
105
  """Model for MCP (Model Context Protocol) server configuration."""
97
106
 
@@ -103,3 +112,38 @@ class MCPServer(rx.Model, table=True):
103
112
  url: str = Field(nullable=False)
104
113
  headers: str = Field(nullable=False, sa_type=EncryptedString)
105
114
  prompt: str = Field(default="", max_length=2000, nullable=True)
115
+
116
+ # Authentication type
117
+ auth_type: str = Field(default=MCPAuthType.NONE, nullable=False)
118
+
119
+ # Optional discovery URL override
120
+ discovery_url: str | None = Field(default=None, nullable=True)
121
+
122
+ # Cached OAuth/Discovery metadata (read-only for user mostly)
123
+ oauth_issuer: str | None = Field(default=None, nullable=True)
124
+ oauth_authorize_url: str | None = Field(default=None, nullable=True)
125
+ oauth_token_url: str | None = Field(default=None, nullable=True)
126
+ oauth_scopes: str | None = Field(
127
+ default=None, nullable=True
128
+ ) # Space separated scopes
129
+
130
+ # Timestamp when discovery was last successfully run
131
+ oauth_discovered_at: datetime | None = Field(
132
+ default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
133
+ )
134
+
135
+
136
+ class SystemPrompt(rx.Model, table=True):
137
+ """Model for system prompt versioning and management.
138
+
139
+ Each save creates a new immutable version. Supports up to 20,000 characters.
140
+ """
141
+
142
+ __tablename__ = "system_prompt"
143
+
144
+ id: int | None = Field(default=None, primary_key=True)
145
+ name: str = Field(max_length=200, nullable=False)
146
+ prompt: str = Field(max_length=20000, nullable=False)
147
+ version: int = Field(nullable=False)
148
+ user_id: int = Field(nullable=False)
149
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@@ -12,7 +12,7 @@ from appkit_assistant.backend.models import (
12
12
  MessageType,
13
13
  )
14
14
  from appkit_assistant.backend.processors.openai_base import BaseOpenAIProcessor
15
- from appkit_assistant.backend.system_prompt import SYSTEM_PROMPT
15
+ from appkit_assistant.backend.system_prompt_cache import get_system_prompt
16
16
 
17
17
  logger = logging.getLogger(__name__)
18
18
 
@@ -404,7 +404,7 @@ class OpenAIResponsesProcessor(BaseOpenAIProcessor):
404
404
  )
405
405
 
406
406
  # Convert messages to responses format with system message
407
- input_messages = self._convert_messages_to_responses_format(
407
+ input_messages = await self._convert_messages_to_responses_format(
408
408
  messages, mcp_prompt=mcp_prompt
409
409
  )
410
410
 
@@ -453,7 +453,7 @@ class OpenAIResponsesProcessor(BaseOpenAIProcessor):
453
453
  prompt_string = "\n".join(prompts) if prompts else ""
454
454
  return tools, prompt_string
455
455
 
456
- def _convert_messages_to_responses_format(
456
+ async def _convert_messages_to_responses_format(
457
457
  self, messages: list[Message], mcp_prompt: str = ""
458
458
  ) -> list[dict[str, Any]]:
459
459
  """Convert messages to the responses API input format.
@@ -471,7 +471,8 @@ class OpenAIResponsesProcessor(BaseOpenAIProcessor):
471
471
  else:
472
472
  mcp_prompt = ""
473
473
 
474
- system_text = SYSTEM_PROMPT.format(mcp_prompts=mcp_prompt)
474
+ system_prompt_template = await get_system_prompt()
475
+ system_text = system_prompt_template.format(mcp_prompts=mcp_prompt)
475
476
  input_messages.append(
476
477
  {
477
478
  "role": "system",
@@ -1,10 +1,11 @@
1
1
  """Repository for MCP server data access operations."""
2
2
 
3
3
  import logging
4
+ from datetime import UTC, datetime
4
5
 
5
6
  import reflex as rx
6
7
 
7
- from appkit_assistant.backend.models import MCPServer
8
+ from appkit_assistant.backend.models import MCPServer, SystemPrompt
8
9
 
9
10
  logger = logging.getLogger(__name__)
10
11
 
@@ -94,3 +95,89 @@ class MCPServerRepository:
94
95
  return True
95
96
  logger.warning("MCP server with ID %s not found for deletion", server_id)
96
97
  return False
98
+
99
+
100
+ class SystemPromptRepository:
101
+ """Repository class for system prompt database operations.
102
+
103
+ Implements append-only versioning with full CRUD capabilities.
104
+ """
105
+
106
+ @staticmethod
107
+ async def get_all() -> list[SystemPrompt]:
108
+ """Retrieve all system prompt versions ordered by version descending."""
109
+ async with rx.asession() as session:
110
+ result = await session.exec(
111
+ SystemPrompt.select().order_by(SystemPrompt.version.desc())
112
+ )
113
+ return result.all()
114
+
115
+ @staticmethod
116
+ async def get_latest() -> SystemPrompt | None:
117
+ """Retrieve the latest system prompt version."""
118
+ async with rx.asession() as session:
119
+ result = await session.exec(
120
+ SystemPrompt.select().order_by(SystemPrompt.version.desc()).limit(1)
121
+ )
122
+ return result.first()
123
+
124
+ @staticmethod
125
+ async def get_by_id(prompt_id: int) -> SystemPrompt | None:
126
+ """Retrieve a system prompt by ID."""
127
+ async with rx.asession() as session:
128
+ result = await session.exec(
129
+ SystemPrompt.select().where(SystemPrompt.id == prompt_id)
130
+ )
131
+ return result.first()
132
+
133
+ @staticmethod
134
+ async def create(prompt: str, user_id: int) -> SystemPrompt:
135
+ """Neue System Prompt Version anlegen.
136
+
137
+ Version ist fortlaufende Ganzzahl, beginnend bei 1.
138
+ """
139
+ async with rx.asession() as session:
140
+ result = await session.exec(
141
+ SystemPrompt.select().order_by(SystemPrompt.version.desc()).limit(1)
142
+ )
143
+ latest = result.first()
144
+ next_version = (latest.version + 1) if latest else 1
145
+
146
+ name = f"Version {next_version}"
147
+
148
+ system_prompt = SystemPrompt(
149
+ name=name,
150
+ prompt=prompt,
151
+ version=next_version,
152
+ user_id=user_id,
153
+ created_at=datetime.now(UTC),
154
+ )
155
+ session.add(system_prompt)
156
+ await session.commit()
157
+ await session.refresh(system_prompt)
158
+
159
+ logger.info(
160
+ "Created system prompt version %s for user %s",
161
+ next_version,
162
+ user_id,
163
+ )
164
+ return system_prompt
165
+
166
+ @staticmethod
167
+ async def delete(prompt_id: int) -> bool:
168
+ """Delete a system prompt version by ID."""
169
+ async with rx.asession() as session:
170
+ result = await session.exec(
171
+ SystemPrompt.select().where(SystemPrompt.id == prompt_id)
172
+ )
173
+ prompt = result.first()
174
+ if prompt:
175
+ await session.delete(prompt)
176
+ await session.commit()
177
+ logger.info("Deleted system prompt version: %s", prompt.version)
178
+ return True
179
+ logger.warning(
180
+ "System prompt with ID %s not found for deletion",
181
+ prompt_id,
182
+ )
183
+ return False
@@ -0,0 +1,161 @@
1
+ import asyncio
2
+ import logging
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import Final
5
+
6
+ from appkit_assistant.backend.repositories import SystemPromptRepository
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ # Cache TTL in seconds (default: 5 minutes)
11
+ CACHE_TTL_SECONDS: Final[int] = 300
12
+
13
+
14
+ class SystemPromptCache:
15
+ """Singleton cache for system prompt with TTL-based invalidation.
16
+
17
+ Features:
18
+ - Lazy loading on first access
19
+ - Automatic cache invalidation after TTL expires
20
+ - Thread-safe with asyncio lock
21
+ - Manual invalidation support for immediate updates
22
+ """
23
+
24
+ _instance: "SystemPromptCache | None" = None
25
+ _lock: asyncio.Lock = asyncio.Lock()
26
+
27
+ def __new__(cls) -> "SystemPromptCache":
28
+ """Ensure singleton pattern."""
29
+ if cls._instance is None:
30
+ cls._instance = super().__new__(cls)
31
+ cls._instance._initialized = False # noqa: SLF001
32
+ return cls._instance
33
+
34
+ def __init__(self) -> None:
35
+ """Initialize cache state (only once due to singleton)."""
36
+ if self._initialized:
37
+ return
38
+
39
+ self._cached_prompt: str | None = None
40
+ self._cached_version: int | None = None
41
+ self._cache_timestamp: datetime | None = None
42
+ self._ttl_seconds: int = CACHE_TTL_SECONDS
43
+ self._initialized = True
44
+
45
+ logger.info(
46
+ "SystemPromptCache initialized with TTL=%d seconds",
47
+ self._ttl_seconds,
48
+ )
49
+
50
+ def _is_cache_valid(self) -> bool:
51
+ """Check if cached prompt is still valid based on TTL."""
52
+ if self._cached_prompt is None or self._cache_timestamp is None:
53
+ return False
54
+
55
+ elapsed = datetime.now(UTC) - self._cache_timestamp
56
+ is_valid = elapsed < timedelta(seconds=self._ttl_seconds)
57
+
58
+ if not is_valid:
59
+ logger.debug("Cache expired after %s seconds", elapsed.total_seconds())
60
+
61
+ return is_valid
62
+
63
+ async def get_prompt(self) -> str:
64
+ """Get the latest system prompt (from cache or database).
65
+
66
+ Returns:
67
+ The current system prompt text.
68
+
69
+ Raises:
70
+ ValueError: If no system prompt exists in database.
71
+ """
72
+ async with self._lock:
73
+ if self._is_cache_valid():
74
+ logger.debug(
75
+ "Cache hit: version=%d, age=%s seconds",
76
+ self._cached_version,
77
+ (datetime.now(UTC) - self._cache_timestamp).total_seconds(),
78
+ )
79
+ return self._cached_prompt
80
+
81
+ # Cache miss or expired - fetch from database
82
+ logger.info("Cache miss - fetching latest prompt from database")
83
+
84
+ latest_prompt = await SystemPromptRepository.get_latest()
85
+
86
+ if latest_prompt is None:
87
+ msg = "No system prompt found in database"
88
+ logger.error(msg)
89
+ raise ValueError(msg)
90
+
91
+ self._cached_prompt = latest_prompt.prompt
92
+ self._cached_version = latest_prompt.version
93
+ self._cache_timestamp = datetime.now(UTC)
94
+
95
+ logger.info(
96
+ "Cached prompt version %d (%d characters)",
97
+ self._cached_version,
98
+ len(self._cached_prompt),
99
+ )
100
+
101
+ return self._cached_prompt
102
+
103
+ async def invalidate(self) -> None:
104
+ """Manually invalidate the cache.
105
+
106
+ Use this when a new prompt version is created to force
107
+ immediate reload on next access.
108
+ """
109
+ async with self._lock:
110
+ if self._cached_prompt is not None:
111
+ logger.info(
112
+ "Cache invalidated (was version %d)",
113
+ self._cached_version,
114
+ )
115
+ self._cached_prompt = None
116
+ self._cached_version = None
117
+ self._cache_timestamp = None
118
+ else:
119
+ logger.debug("Cache invalidation called but cache was empty")
120
+
121
+ def set_ttl(self, seconds: int) -> None:
122
+ """Update cache TTL.
123
+
124
+ Args:
125
+ seconds: New TTL in seconds.
126
+ """
127
+ self._ttl_seconds = seconds
128
+ logger.info("Cache TTL updated to %d seconds", seconds)
129
+
130
+ @property
131
+ def is_cached(self) -> bool:
132
+ """Check if prompt is currently cached and valid."""
133
+ return self._is_cache_valid()
134
+
135
+ @property
136
+ def cached_version(self) -> int | None:
137
+ """Get the currently cached prompt version (if any)."""
138
+ return self._cached_version if self._is_cache_valid() else None
139
+
140
+
141
+ # Global cache instance
142
+ _prompt_cache = SystemPromptCache()
143
+
144
+
145
+ async def get_system_prompt() -> str:
146
+ """Convenience function to get the current system prompt.
147
+
148
+ Returns:
149
+ The current system prompt text.
150
+ """
151
+ return await _prompt_cache.get_prompt()
152
+
153
+
154
+ async def invalidate_prompt_cache() -> None:
155
+ """Convenience function to invalidate the prompt cache."""
156
+ await _prompt_cache.invalidate()
157
+
158
+
159
+ def get_cache_instance() -> SystemPromptCache:
160
+ """Get the global cache instance for advanced usage."""
161
+ return _prompt_cache
@@ -0,0 +1,78 @@
1
+ import reflex as rx
2
+
3
+ import appkit_mantine as mn
4
+ from appkit_assistant.state.system_prompt_state import SystemPromptState
5
+ from appkit_ui.components.dialogs import delete_dialog
6
+
7
+
8
+ def system_prompt_editor() -> rx.Component:
9
+ """Admin-UI für das System Prompt Versioning mit appkit-mantine & appkit-ui.
10
+
11
+ Uses a hybrid approach for the textarea:
12
+ - default_value + on_change prevents cursor jumping during typing
13
+ - key prop forces re-render when selecting a different version
14
+ - This gives us both smooth editing AND the ability to update from select
15
+ """
16
+ return rx.vstack(
17
+ mn.markdown_preview(
18
+ source=(
19
+ """
20
+ Der System-Prompt legt fest, wie sich der Assistent verhält. Bitte stellen Sie sicher,
21
+ dass der Platzhalter `{mcp_prompts}` immer im Text enthalten ist. Dieser Platzhalter ist
22
+ notwendig, damit das System im Hintergrund die benötigten Funktionen und Werkzeuge
23
+ automatisch einfügen kann."""
24
+ ),
25
+ width="100%",
26
+ ),
27
+ mn.textarea(
28
+ placeholder="System-Prompt hier eingeben (max. 10.000 Zeichen)...",
29
+ description=f"{SystemPromptState.char_count} / 10.000 Zeichen",
30
+ default_value=SystemPromptState.current_prompt,
31
+ on_change=SystemPromptState.set_current_prompt,
32
+ error=SystemPromptState.error_message,
33
+ rows=21,
34
+ variant="filled",
35
+ width="100%",
36
+ key=SystemPromptState.textarea_key,
37
+ ),
38
+ rx.hstack(
39
+ mn.select(
40
+ placeholder="Aktuell",
41
+ data=SystemPromptState.versions,
42
+ value=SystemPromptState.selected_version_str,
43
+ on_change=SystemPromptState.set_selected_version,
44
+ clearable=False,
45
+ searchable=False,
46
+ width="280px",
47
+ ),
48
+ delete_dialog(
49
+ title="Version endgültig löschen?",
50
+ content="die ausgewählte Version",
51
+ on_click=SystemPromptState.delete_version,
52
+ icon_button=True,
53
+ class_name="dialog",
54
+ variant="outline",
55
+ color_scheme="red",
56
+ disabled=SystemPromptState.is_loading
57
+ | (SystemPromptState.selected_version_id == 0),
58
+ ),
59
+ rx.spacer(),
60
+ rx.button(
61
+ "Neue Version speichern",
62
+ on_click=SystemPromptState.save_current,
63
+ disabled=SystemPromptState.is_loading
64
+ | (
65
+ SystemPromptState.current_prompt
66
+ == SystemPromptState.last_saved_prompt
67
+ ),
68
+ loading=SystemPromptState.is_loading,
69
+ ),
70
+ align="center",
71
+ width="100%",
72
+ spacing="4",
73
+ ),
74
+ width="100%",
75
+ max_width="960px",
76
+ padding="6",
77
+ spacing="5",
78
+ )
@@ -0,0 +1,181 @@
1
+ import logging
2
+ from collections.abc import AsyncGenerator
3
+ from typing import Any, Final
4
+
5
+ import reflex as rx
6
+ from reflex.state import State
7
+
8
+ from appkit_assistant.backend.repositories import SystemPromptRepository
9
+ from appkit_assistant.backend.system_prompt_cache import invalidate_prompt_cache
10
+ from appkit_user.authentication.states import UserSession
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ MAX_PROMPT_LENGTH: Final[int] = 10000
15
+
16
+
17
+ class SystemPromptState(State):
18
+ """State für System Prompt Editing und Versionierung."""
19
+
20
+ current_prompt: str = ""
21
+ last_saved_prompt: str = ""
22
+ versions: list[dict[str, str | int]] = []
23
+ prompt_map: dict[str, str] = {}
24
+ selected_version_id: int = 0
25
+ is_loading: bool = False
26
+ error_message: str = ""
27
+ char_count: int = 0
28
+ # Trigger to force textarea update when selecting a version
29
+ textarea_key: int = 0
30
+
31
+ async def load_versions(self) -> None:
32
+ """Alle System Prompt Versionen laden."""
33
+ self.is_loading = True
34
+ self.error_message = ""
35
+ try:
36
+ prompts = await SystemPromptRepository.get_all()
37
+ self.versions = [
38
+ {
39
+ "value": str(p.version),
40
+ "label": (
41
+ f"Version {p.version} - "
42
+ f"{p.created_at.strftime('%d.%m.%Y %H:%M')}"
43
+ ),
44
+ }
45
+ for p in prompts
46
+ ]
47
+
48
+ # Populate map for fast switching
49
+ self.prompt_map = {str(p.version): p.prompt for p in prompts}
50
+
51
+ if prompts:
52
+ latest = prompts[0]
53
+ # Automatically select the latest version
54
+ self.selected_version_id = latest.version
55
+
56
+ if not self.current_prompt:
57
+ self.current_prompt = latest.prompt
58
+ self.last_saved_prompt = latest.prompt
59
+ else:
60
+ self.last_saved_prompt = latest.prompt
61
+ else:
62
+ self.selected_version_id = 0
63
+ if not self.current_prompt:
64
+ self.current_prompt = ""
65
+ self.last_saved_prompt = self.current_prompt
66
+
67
+ # Zähler initial setzen
68
+ self.char_count = len(self.current_prompt)
69
+
70
+ logger.info("Loaded %s system prompt versions", len(self.versions))
71
+ except Exception as exc:
72
+ self.error_message = f"Fehler beim Laden: {exc!s}"
73
+ logger.exception("Failed to load system prompt versions")
74
+ finally:
75
+ self.is_loading = False
76
+
77
+ async def save_current(self) -> AsyncGenerator[Any, Any]:
78
+ if self.current_prompt == self.last_saved_prompt:
79
+ yield rx.toast.info("Es wurden keine Änderungen erkannt.")
80
+ return
81
+
82
+ if not self.current_prompt.strip():
83
+ self.error_message = "Prompt darf nicht leer sein."
84
+ yield rx.toast.error("Prompt darf nicht leer sein.")
85
+ return
86
+
87
+ if len(self.current_prompt) > MAX_PROMPT_LENGTH:
88
+ self.error_message = "Prompt darf maximal 20.000 Zeichen enthalten."
89
+ yield rx.toast.error("Prompt ist zu lang (max. 20.000 Zeichen).")
90
+ return
91
+
92
+ self.is_loading = True
93
+ self.error_message = ""
94
+ try:
95
+ user_session: UserSession = await self.get_state(UserSession)
96
+ user_id = user_session.user_id
97
+
98
+ await SystemPromptRepository.create(
99
+ prompt=self.current_prompt,
100
+ user_id=user_id,
101
+ )
102
+
103
+ self.last_saved_prompt = self.current_prompt
104
+
105
+ # Invalidate cache to force reload of new prompt version
106
+ await invalidate_prompt_cache()
107
+ logger.info("System prompt cache invalidated after save")
108
+
109
+ await self.load_versions()
110
+
111
+ yield rx.toast.success("Neue Version erfolgreich gespeichert.")
112
+ logger.info("Saved new system prompt version by user %s", user_id)
113
+ except Exception as exc:
114
+ self.error_message = f"Fehler beim Speichern: {exc!s}"
115
+ logger.exception("Failed to save system prompt")
116
+ yield rx.toast.error(f"Fehler: {exc!s}")
117
+ finally:
118
+ self.is_loading = False
119
+
120
+ async def delete_version(self) -> AsyncGenerator[Any, Any]:
121
+ if not self.selected_version_id:
122
+ self.error_message = "Keine Version ausgewählt."
123
+ yield rx.toast.error("Bitte zuerst eine Version auswählen.")
124
+ return
125
+
126
+ self.is_loading = True
127
+ self.error_message = ""
128
+ try:
129
+ success = await SystemPromptRepository.delete(self.selected_version_id)
130
+ if success:
131
+ self.selected_version_id = 0
132
+
133
+ # Invalidate cache since latest version might have changed
134
+ await invalidate_prompt_cache()
135
+ logger.info("System prompt cache invalidated after deletion")
136
+
137
+ await self.load_versions()
138
+ yield rx.toast.success("Version erfolgreich gelöscht.")
139
+ else:
140
+ self.error_message = "Version nicht gefunden."
141
+ yield rx.toast.error("Version nicht gefunden.")
142
+ except Exception as exc:
143
+ self.error_message = f"Fehler beim Löschen: {exc!s}"
144
+ logger.exception("Failed to delete version")
145
+ yield rx.toast.error(f"Fehler: {exc!s}")
146
+ finally:
147
+ self.is_loading = False
148
+
149
+ def set_current_prompt(self, value: str) -> None:
150
+ """Update current prompt text and char count.
151
+
152
+ This is called on every keystroke but doesn't cause cursor jumping
153
+ because we use default_value in the textarea component.
154
+ """
155
+ self.current_prompt = value
156
+ self.char_count = len(value)
157
+
158
+ def set_selected_version(self, value: str | None) -> None:
159
+ """Handle version selection and load corresponding prompt.
160
+
161
+ When a version is selected, we update the prompt content and force
162
+ the textarea to re-render by changing its key.
163
+ """
164
+ if value is None or value == "":
165
+ return
166
+
167
+ self.selected_version_id = int(value)
168
+
169
+ # Load the prompt for the selected version
170
+ if value in self.prompt_map:
171
+ self.current_prompt = self.prompt_map[value]
172
+ self.char_count = len(self.current_prompt)
173
+ # Force textarea to re-render with new content
174
+ self.textarea_key += 1
175
+
176
+ @rx.var
177
+ def selected_version_str(self) -> str:
178
+ """Return selected version as string for the select component."""
179
+ if self.selected_version_id == 0:
180
+ return ""
181
+ return str(self.selected_version_id)