appkit-assistant 0.8.0__py3-none-any.whl → 0.10.0__py3-none-any.whl

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.
@@ -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
+ )
@@ -4,15 +4,11 @@ from collections.abc import Callable
4
4
  import reflex as rx
5
5
 
6
6
  import appkit_mantine as mn
7
+ from appkit_assistant.backend.models import Message, MessageType
7
8
  from appkit_assistant.components import composer
8
9
  from appkit_assistant.components.message import MessageComponent
9
10
  from appkit_assistant.components.threadlist import ThreadList
10
- from appkit_assistant.state.thread_state import (
11
- Message,
12
- MessageType,
13
- ThreadListState,
14
- ThreadState,
15
- )
11
+ from appkit_assistant.state.thread_state import ThreadState
16
12
 
17
13
  logger = logging.getLogger(__name__)
18
14
 
@@ -55,7 +51,7 @@ class Assistant:
55
51
  lambda suggestion: Assistant.suggestion(
56
52
  prompt=suggestion.prompt,
57
53
  icon=suggestion.icon,
58
- update_prompt=ThreadState.update_prompt,
54
+ update_prompt=ThreadState.set_prompt,
59
55
  ),
60
56
  ),
61
57
  spacing="4",
@@ -122,7 +118,7 @@ class Assistant:
122
118
  ),
123
119
  rx.hstack(
124
120
  composer.tools(
125
- show=with_tools and ThreadState.current_model_supports_tools
121
+ show=with_tools and ThreadState.selected_model_supports_tools
126
122
  ),
127
123
  composer.add_attachment(show=with_attachments),
128
124
  composer.clear(show=with_clear),
@@ -156,9 +152,6 @@ class Assistant:
156
152
  # if suggestions is not None:
157
153
  # ThreadState.set_suggestions(suggestions)
158
154
 
159
- if with_thread_list:
160
- ThreadState.with_thread_list = with_thread_list
161
-
162
155
  return rx.flex(
163
156
  rx.cond(
164
157
  ThreadState.messages,
@@ -207,7 +200,10 @@ class Assistant:
207
200
  spacing="0",
208
201
  flex_shrink=0,
209
202
  z_index=1000,
210
- on_mount=ThreadState.load_available_mcp_servers,
203
+ on_mount=[
204
+ ThreadState.set_with_thread_list(with_thread_list),
205
+ ThreadState.load_mcp_servers,
206
+ ],
211
207
  ),
212
208
  **props,
213
209
  )
@@ -216,12 +212,8 @@ class Assistant:
216
212
  def thread_list(
217
213
  *items,
218
214
  with_footer: bool = False,
219
- default_model: str | None = None,
220
215
  **props,
221
216
  ) -> rx.Component:
222
- if default_model:
223
- ThreadListState.default_model = default_model
224
-
225
217
  return rx.flex(
226
218
  rx.flex(
227
219
  ThreadList.header(
@@ -1,6 +1,8 @@
1
1
  import reflex as rx
2
2
 
3
- from appkit_assistant.state.thread_state import ThreadListState, ThreadModel
3
+ from appkit_assistant.backend.models import ThreadModel
4
+ from appkit_assistant.state.thread_list_state import ThreadListState
5
+ from appkit_assistant.state.thread_state import ThreadState
4
6
 
5
7
 
6
8
  class ThreadList:
@@ -13,7 +15,7 @@ class ThreadList:
13
15
  rx.text(title),
14
16
  size="2",
15
17
  margin_right="28px",
16
- on_click=ThreadListState.create_thread(),
18
+ on_click=ThreadState.new_thread(),
17
19
  width="95%",
18
20
  ),
19
21
  content="Neuen Chat starten",
@@ -46,24 +48,28 @@ class ThreadList:
46
48
  min_width="0",
47
49
  title=thread.title,
48
50
  ),
49
- rx.tooltip(
50
- rx.button(
51
- rx.icon(
52
- "trash",
53
- size=13,
54
- stroke_width=1.5,
51
+ rx.cond(
52
+ ThreadListState.loading_thread_id == thread.thread_id,
53
+ rx.spinner(size="1", margin_left="6px", margin_right="6px"),
54
+ rx.tooltip(
55
+ rx.button(
56
+ rx.icon(
57
+ "trash",
58
+ size=13,
59
+ stroke_width=1.5,
60
+ ),
61
+ variant="ghost",
62
+ size="1",
63
+ margin_left="0px",
64
+ margin_right="0px",
65
+ color_scheme="gray",
66
+ on_click=ThreadListState.delete_thread(thread.thread_id),
55
67
  ),
56
- variant="ghost",
57
- size="1",
58
- margin_left="0px",
59
- margin_right="0px",
60
- color_scheme="gray",
61
- on_click=ThreadListState.delete_thread(thread.thread_id),
68
+ content="Chat löschen",
69
+ flex_shrink=0,
62
70
  ),
63
- content="Chat löschen",
64
- flex_shrink=0,
65
71
  ),
66
- on_click=ThreadListState.select_thread(thread.thread_id),
72
+ on_click=ThreadState.load_thread(thread.thread_id),
67
73
  flex_direction=["row"],
68
74
  margin_right="10px",
69
75
  margin_bottom="8px",
@@ -113,18 +119,25 @@ class ThreadList:
113
119
  ThreadListState.threads,
114
120
  ThreadList.thread_list_item,
115
121
  ),
116
- rx.text(
117
- "Keine Chats vorhanden.",
118
- size="2",
119
- white_space="nowrap",
120
- overflow="hidden",
121
- text_overflow="ellipsis",
122
- flex_grow="1",
123
- min_width="0",
124
- margin_right="10px",
125
- margin_bottom="8px",
126
- padding="6px",
127
- align="center",
122
+ rx.cond(
123
+ ThreadListState.loading,
124
+ rx.vstack(
125
+ rx.skeleton(height="34px", width="210px"),
126
+ rx.skeleton(height="34px", width="210px"),
127
+ rx.skeleton(height="34px", width="210px"),
128
+ spacing="2",
129
+ ),
130
+ rx.text(
131
+ "Noch keine Chats vorhanden. "
132
+ "Klicke auf 'Neuer Chat', um zu beginnen.",
133
+ size="2",
134
+ flex_grow="1",
135
+ min_width="0",
136
+ margin_right="10px",
137
+ margin_bottom="8px",
138
+ padding="6px",
139
+ align="center",
140
+ ),
128
141
  ),
129
142
  ),
130
143
  scrollbars="vertical",
@@ -92,6 +92,6 @@ def tools_popover() -> rx.Component:
92
92
  side="top",
93
93
  ),
94
94
  open=ThreadState.show_tools_modal,
95
- on_open_change=ThreadState.set_show_tools_modal,
95
+ on_open_change=ThreadState.toogle_tools_modal,
96
96
  placement="bottom-start",
97
97
  )
@@ -8,3 +8,4 @@ class AssistantConfig(BaseConfig):
8
8
  openai_base_url: str | None = None
9
9
  openai_api_key: SecretStr | None = None
10
10
  google_api_key: SecretStr | None = None
11
+ azure_ai_projects_endpoint: str | None = None
@@ -0,0 +1,179 @@
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
+ textarea_key: int = 0
29
+
30
+ async def load_versions(self) -> None:
31
+ """Alle System Prompt Versionen laden."""
32
+ self.is_loading = True
33
+ self.error_message = ""
34
+ try:
35
+ prompts = await SystemPromptRepository.get_all()
36
+ self.versions = [
37
+ {
38
+ "value": str(p.version),
39
+ "label": (
40
+ f"Version {p.version} - "
41
+ f"{p.created_at.strftime('%d.%m.%Y %H:%M')}"
42
+ ),
43
+ }
44
+ for p in prompts
45
+ ]
46
+
47
+ self.prompt_map = {str(p.version): p.prompt for p in prompts}
48
+
49
+ if prompts:
50
+ latest = prompts[0]
51
+ self.selected_version_id = latest.version
52
+
53
+ if not self.current_prompt:
54
+ self.current_prompt = latest.prompt
55
+ self.last_saved_prompt = latest.prompt
56
+ else:
57
+ self.last_saved_prompt = latest.prompt
58
+ else:
59
+ self.selected_version_id = 0
60
+ if not self.current_prompt:
61
+ self.current_prompt = ""
62
+ self.last_saved_prompt = self.current_prompt
63
+
64
+ self.char_count = len(self.current_prompt)
65
+ # Force textarea to re-render with loaded content
66
+ self.textarea_key += 1
67
+
68
+ logger.info("Loaded %s system prompt versions", len(self.versions))
69
+ except Exception as exc:
70
+ self.error_message = f"Fehler beim Laden: {exc!s}"
71
+ logger.exception("Failed to load system prompt versions")
72
+ finally:
73
+ self.is_loading = False
74
+
75
+ async def save_current(self) -> AsyncGenerator[Any, Any]:
76
+ if self.current_prompt == self.last_saved_prompt:
77
+ yield rx.toast.info("Es wurden keine Änderungen erkannt.")
78
+ return
79
+
80
+ if not self.current_prompt.strip():
81
+ self.error_message = "Prompt darf nicht leer sein."
82
+ yield rx.toast.error("Prompt darf nicht leer sein.")
83
+ return
84
+
85
+ if len(self.current_prompt) > MAX_PROMPT_LENGTH:
86
+ self.error_message = "Prompt darf maximal 20.000 Zeichen enthalten."
87
+ yield rx.toast.error("Prompt ist zu lang (max. 20.000 Zeichen).")
88
+ return
89
+
90
+ self.is_loading = True
91
+ self.error_message = ""
92
+ try:
93
+ user_session: UserSession = await self.get_state(UserSession)
94
+ user_id = user_session.user_id
95
+
96
+ await SystemPromptRepository.create(
97
+ prompt=self.current_prompt,
98
+ user_id=user_id,
99
+ )
100
+
101
+ self.last_saved_prompt = self.current_prompt
102
+
103
+ # Invalidate cache to force reload of new prompt version
104
+ await invalidate_prompt_cache()
105
+ logger.info("System prompt cache invalidated after save")
106
+
107
+ await self.load_versions()
108
+
109
+ yield rx.toast.success("Neue Version erfolgreich gespeichert.")
110
+ logger.info("Saved new system prompt version by user %s", user_id)
111
+ except Exception as exc:
112
+ self.error_message = f"Fehler beim Speichern: {exc!s}"
113
+ logger.exception("Failed to save system prompt")
114
+ yield rx.toast.error(f"Fehler: {exc!s}")
115
+ finally:
116
+ self.is_loading = False
117
+
118
+ async def delete_version(self) -> AsyncGenerator[Any, Any]:
119
+ if not self.selected_version_id:
120
+ self.error_message = "Keine Version ausgewählt."
121
+ yield rx.toast.error("Bitte zuerst eine Version auswählen.")
122
+ return
123
+
124
+ self.is_loading = True
125
+ self.error_message = ""
126
+ try:
127
+ success = await SystemPromptRepository.delete(self.selected_version_id)
128
+ if success:
129
+ self.selected_version_id = 0
130
+
131
+ # Invalidate cache since latest version might have changed
132
+ await invalidate_prompt_cache()
133
+ logger.info("System prompt cache invalidated after deletion")
134
+
135
+ await self.load_versions()
136
+ yield rx.toast.success("Version erfolgreich gelöscht.")
137
+ else:
138
+ self.error_message = "Version nicht gefunden."
139
+ yield rx.toast.error("Version nicht gefunden.")
140
+ except Exception as exc:
141
+ self.error_message = f"Fehler beim Löschen: {exc!s}"
142
+ logger.exception("Failed to delete version")
143
+ yield rx.toast.error(f"Fehler: {exc!s}")
144
+ finally:
145
+ self.is_loading = False
146
+
147
+ def set_current_prompt(self, value: str) -> None:
148
+ """Update current prompt text and char count.
149
+
150
+ This is called on every keystroke but doesn't cause cursor jumping
151
+ because we use default_value in the textarea component.
152
+ """
153
+ self.current_prompt = value
154
+ self.char_count = len(value)
155
+
156
+ def set_selected_version(self, value: str | None) -> None:
157
+ """Handle version selection and load corresponding prompt.
158
+
159
+ When a version is selected, we update the prompt content and force
160
+ the textarea to re-render by changing its key.
161
+ """
162
+ if value is None or value == "":
163
+ return
164
+
165
+ self.selected_version_id = int(value)
166
+
167
+ # Load the prompt for the selected version
168
+ if value in self.prompt_map:
169
+ self.current_prompt = self.prompt_map[value]
170
+ self.char_count = len(self.current_prompt)
171
+ # Force textarea to re-render with new content
172
+ self.textarea_key += 1
173
+
174
+ @rx.var
175
+ def selected_version_str(self) -> str:
176
+ """Return selected version as string for the select component."""
177
+ if self.selected_version_id == 0:
178
+ return ""
179
+ return str(self.selected_version_id)