appkit-assistant 0.13.0__tar.gz → 0.14.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 (31) hide show
  1. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/PKG-INFO +26 -9
  2. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/README.md +25 -8
  3. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/pyproject.toml +1 -1
  4. appkit_assistant-0.14.0/src/appkit_assistant/backend/repositories.py +162 -0
  5. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/backend/system_prompt_cache.py +14 -4
  6. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/mcp_server_dialogs.py +1 -1
  7. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/state/mcp_server_state.py +55 -28
  8. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/state/system_prompt_state.py +51 -27
  9. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/state/thread_list_state.py +25 -4
  10. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/state/thread_state.py +72 -13
  11. appkit_assistant-0.13.0/src/appkit_assistant/backend/repositories.py +0 -323
  12. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/.gitignore +0 -0
  13. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/docs/assistant.png +0 -0
  14. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/backend/model_manager.py +0 -0
  15. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/backend/models.py +0 -0
  16. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/backend/processor.py +0 -0
  17. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/backend/processors/lorem_ipsum_processor.py +0 -0
  18. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/backend/processors/openai_base.py +0 -0
  19. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/backend/processors/openai_chat_completion_processor.py +0 -0
  20. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/backend/processors/openai_responses_processor.py +0 -0
  21. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/backend/processors/perplexity_processor.py +0 -0
  22. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/__init__.py +0 -0
  23. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/composer.py +0 -0
  24. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/composer_key_handler.py +0 -0
  25. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/mcp_server_table.py +0 -0
  26. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/message.py +0 -0
  27. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/system_prompt_editor.py +0 -0
  28. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/thread.py +0 -0
  29. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/threadlist.py +0 -0
  30. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/components/tools_modal.py +0 -0
  31. {appkit_assistant-0.13.0 → appkit_assistant-0.14.0}/src/appkit_assistant/configuration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appkit-assistant
3
- Version: 0.13.0
3
+ Version: 0.14.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
@@ -40,6 +40,7 @@ appkit-assistant provides a complete conversational AI interface built on Reflex
40
40
 
41
41
  - **Multi-Model Support** - OpenAI Chat Completions, OpenAI Responses API, Perplexity, and fallback Lorem Ipsum processor
42
42
  - **MCP Server Integration** - Manage and connect to Model Context Protocol servers as tools
43
+ - **System Prompt Management** - Versioned system prompts with admin editor interface
43
44
  - **Secure Credential Management** - Encrypted storage and handling of API keys and server credentials
44
45
  - **Reflex UI Components** - Pre-built assistant interface with composer, thread management, and message display
45
46
  - **Streaming Responses** - Real-time streaming of AI responses with chunked content
@@ -114,11 +115,11 @@ manager.register_processor("perplexity", PerplexityProcessor(assistant_config))
114
115
 
115
116
  ```python
116
117
  import reflex as rx
117
- import appkit_assistant as assistant
118
+ from appkit_assistant.components import Assistant
118
119
 
119
120
  def assistant_page():
120
121
  return rx.container(
121
- assistant.Assistant(),
122
+ Assistant(),
122
123
  height="100vh"
123
124
  )
124
125
  ```
@@ -188,10 +189,10 @@ async for chunk in processor.process(messages, "gpt-4", mcp_servers=[mcp_server]
188
189
  The main `Assistant` component provides a complete chat interface:
189
190
 
190
191
  ```python
191
- import appkit_assistant as assistant
192
+ from appkit_assistant.components import Assistant
192
193
 
193
194
  def chat_page():
194
- return assistant.Assistant()
195
+ return Assistant()
195
196
  ```
196
197
 
197
198
  #### Individual Components
@@ -199,12 +200,13 @@ def chat_page():
199
200
  Use individual components for custom layouts:
200
201
 
201
202
  ```python
202
- import appkit_assistant as assistant
203
+ import reflex as rx
204
+ from appkit_assistant.components import ThreadList, composer
203
205
 
204
206
  def custom_assistant():
205
207
  return rx.vstack(
206
- assistant.ThreadList(),
207
- assistant.composer(),
208
+ ThreadList(),
209
+ composer(),
208
210
  spacing="4"
209
211
  )
210
212
  ```
@@ -214,8 +216,21 @@ def custom_assistant():
214
216
  Display and manage MCP servers:
215
217
 
216
218
  ```python
219
+ from appkit_assistant.components import mcp_servers_table
220
+
217
221
  def servers_page():
218
- return assistant.mcp_servers_table()
222
+ return mcp_servers_table()
223
+ ```
224
+
225
+ #### System Prompt Editor
226
+
227
+ Admin interface for managing versioned system prompts:
228
+
229
+ ```python
230
+ from appkit_assistant.components.system_prompt_editor import system_prompt_editor
231
+
232
+ def prompt_editor_page():
233
+ return system_prompt_editor()
219
234
  ```
220
235
 
221
236
  ---
@@ -279,6 +294,8 @@ manager.register_processor("lorem", LoremIpsumProcessor())
279
294
  - `ThreadList` - Conversation thread list
280
295
  - `MessageComponent` - Individual message display
281
296
  - `mcp_servers_table` - MCP server management table
297
+ - `system_prompt_editor` - Admin interface for system prompts
298
+
282
299
 
283
300
  ### State Management
284
301
 
@@ -15,6 +15,7 @@ appkit-assistant provides a complete conversational AI interface built on Reflex
15
15
 
16
16
  - **Multi-Model Support** - OpenAI Chat Completions, OpenAI Responses API, Perplexity, and fallback Lorem Ipsum processor
17
17
  - **MCP Server Integration** - Manage and connect to Model Context Protocol servers as tools
18
+ - **System Prompt Management** - Versioned system prompts with admin editor interface
18
19
  - **Secure Credential Management** - Encrypted storage and handling of API keys and server credentials
19
20
  - **Reflex UI Components** - Pre-built assistant interface with composer, thread management, and message display
20
21
  - **Streaming Responses** - Real-time streaming of AI responses with chunked content
@@ -89,11 +90,11 @@ manager.register_processor("perplexity", PerplexityProcessor(assistant_config))
89
90
 
90
91
  ```python
91
92
  import reflex as rx
92
- import appkit_assistant as assistant
93
+ from appkit_assistant.components import Assistant
93
94
 
94
95
  def assistant_page():
95
96
  return rx.container(
96
- assistant.Assistant(),
97
+ Assistant(),
97
98
  height="100vh"
98
99
  )
99
100
  ```
@@ -163,10 +164,10 @@ async for chunk in processor.process(messages, "gpt-4", mcp_servers=[mcp_server]
163
164
  The main `Assistant` component provides a complete chat interface:
164
165
 
165
166
  ```python
166
- import appkit_assistant as assistant
167
+ from appkit_assistant.components import Assistant
167
168
 
168
169
  def chat_page():
169
- return assistant.Assistant()
170
+ return Assistant()
170
171
  ```
171
172
 
172
173
  #### Individual Components
@@ -174,12 +175,13 @@ def chat_page():
174
175
  Use individual components for custom layouts:
175
176
 
176
177
  ```python
177
- import appkit_assistant as assistant
178
+ import reflex as rx
179
+ from appkit_assistant.components import ThreadList, composer
178
180
 
179
181
  def custom_assistant():
180
182
  return rx.vstack(
181
- assistant.ThreadList(),
182
- assistant.composer(),
183
+ ThreadList(),
184
+ composer(),
183
185
  spacing="4"
184
186
  )
185
187
  ```
@@ -189,8 +191,21 @@ def custom_assistant():
189
191
  Display and manage MCP servers:
190
192
 
191
193
  ```python
194
+ from appkit_assistant.components import mcp_servers_table
195
+
192
196
  def servers_page():
193
- return assistant.mcp_servers_table()
197
+ return mcp_servers_table()
198
+ ```
199
+
200
+ #### System Prompt Editor
201
+
202
+ Admin interface for managing versioned system prompts:
203
+
204
+ ```python
205
+ from appkit_assistant.components.system_prompt_editor import system_prompt_editor
206
+
207
+ def prompt_editor_page():
208
+ return system_prompt_editor()
194
209
  ```
195
210
 
196
211
  ---
@@ -254,6 +269,8 @@ manager.register_processor("lorem", LoremIpsumProcessor())
254
269
  - `ThreadList` - Conversation thread list
255
270
  - `MessageComponent` - Individual message display
256
271
  - `mcp_servers_table` - MCP server management table
272
+ - `system_prompt_editor` - Admin interface for system prompts
273
+
257
274
 
258
275
  ### State Management
259
276
 
@@ -7,7 +7,7 @@ dependencies = [
7
7
  "reflex>=0.8.22",
8
8
  ]
9
9
  name = "appkit-assistant"
10
- version = "0.13.0"
10
+ version = "0.14.0"
11
11
  description = "Add your description here"
12
12
  readme = "README.md"
13
13
  authors = [{ name = "Jens Rehpöhler" }]
@@ -0,0 +1,162 @@
1
+ """Repository for MCP server data access operations."""
2
+
3
+ import logging
4
+ from datetime import UTC, datetime
5
+
6
+ from sqlalchemy import select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy.orm import defer
9
+
10
+ from appkit_assistant.backend.models import (
11
+ AssistantThread,
12
+ MCPServer,
13
+ SystemPrompt,
14
+ )
15
+ from appkit_commons.database.base_repository import BaseRepository
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class MCPServerRepository(BaseRepository[MCPServer, AsyncSession]):
21
+ """Repository class for MCP server database operations."""
22
+
23
+ @property
24
+ def model_class(self) -> type[MCPServer]:
25
+ return MCPServer
26
+
27
+ async def find_all_ordered_by_name(self, session: AsyncSession) -> list[MCPServer]:
28
+ """Retrieve all MCP servers ordered by name."""
29
+ stmt = select(MCPServer).order_by(MCPServer.name)
30
+ result = await session.execute(stmt)
31
+ return list(result.scalars().all())
32
+
33
+
34
+ class SystemPromptRepository(BaseRepository[SystemPrompt, AsyncSession]):
35
+ """Repository class for system prompt database operations.
36
+
37
+ Implements append-only versioning with full CRUD capabilities.
38
+ """
39
+
40
+ @property
41
+ def model_class(self) -> type[SystemPrompt]:
42
+ return SystemPrompt
43
+
44
+ async def find_all_ordered_by_version_desc(
45
+ self, session: AsyncSession
46
+ ) -> list[SystemPrompt]:
47
+ """Retrieve all system prompt versions ordered by version descending."""
48
+ stmt = select(SystemPrompt).order_by(SystemPrompt.version.desc())
49
+ result = await session.execute(stmt)
50
+ return list(result.scalars().all())
51
+
52
+ async def find_latest(self, session: AsyncSession) -> SystemPrompt | None:
53
+ """Retrieve the latest system prompt version."""
54
+ stmt = select(SystemPrompt).order_by(SystemPrompt.version.desc()).limit(1)
55
+ result = await session.execute(stmt)
56
+ return result.scalars().first()
57
+
58
+ async def create_next_version(
59
+ self, session: AsyncSession, prompt: str, user_id: int
60
+ ) -> SystemPrompt:
61
+ """Neue System Prompt Version anlegen.
62
+
63
+ Version ist fortlaufende Ganzzahl, beginnend bei 1.
64
+ """
65
+ stmt = select(SystemPrompt).order_by(SystemPrompt.version.desc()).limit(1)
66
+ result = await session.execute(stmt)
67
+ latest = result.scalars().first()
68
+ next_version = (latest.version + 1) if latest else 1
69
+
70
+ name = f"Version {next_version}"
71
+
72
+ system_prompt = SystemPrompt(
73
+ name=name,
74
+ prompt=prompt,
75
+ version=next_version,
76
+ user_id=user_id,
77
+ created_at=datetime.now(UTC),
78
+ )
79
+ session.add(system_prompt)
80
+ await session.flush()
81
+ await session.refresh(system_prompt)
82
+
83
+ logger.info(
84
+ "Created system prompt version %s for user %s",
85
+ next_version,
86
+ user_id,
87
+ )
88
+ return system_prompt
89
+
90
+
91
+ class ThreadRepository(BaseRepository[AssistantThread, AsyncSession]):
92
+ """Repository class for Thread database operations."""
93
+
94
+ @property
95
+ def model_class(self) -> type[AssistantThread]:
96
+ return AssistantThread
97
+
98
+ async def find_by_user(
99
+ self, session: AsyncSession, user_id: int
100
+ ) -> list[AssistantThread]:
101
+ """Retrieve all threads for a user."""
102
+ stmt = (
103
+ select(AssistantThread)
104
+ .where(AssistantThread.user_id == user_id)
105
+ .order_by(AssistantThread.updated_at.desc())
106
+ )
107
+ result = await session.execute(stmt)
108
+ return list(result.scalars().all())
109
+
110
+ async def find_by_thread_id(
111
+ self, session: AsyncSession, thread_id: str
112
+ ) -> AssistantThread | None:
113
+ """Retrieve a thread by its thread_id."""
114
+ stmt = select(AssistantThread).where(AssistantThread.thread_id == thread_id)
115
+ result = await session.execute(stmt)
116
+ return result.scalars().first()
117
+
118
+ async def find_by_thread_id_and_user(
119
+ self, session: AsyncSession, thread_id: str, user_id: int
120
+ ) -> AssistantThread | None:
121
+ """Retrieve a thread by thread_id and user_id."""
122
+ stmt = select(AssistantThread).where(
123
+ AssistantThread.thread_id == thread_id,
124
+ AssistantThread.user_id == user_id,
125
+ )
126
+ result = await session.execute(stmt)
127
+ return result.scalars().first()
128
+
129
+ async def delete_by_thread_id_and_user(
130
+ self, session: AsyncSession, thread_id: str, user_id: int
131
+ ) -> bool:
132
+ """Delete a thread by thread_id and user_id."""
133
+ stmt = select(AssistantThread).where(
134
+ AssistantThread.thread_id == thread_id,
135
+ AssistantThread.user_id == user_id,
136
+ )
137
+ result = await session.execute(stmt)
138
+ thread = result.scalars().first()
139
+ if thread:
140
+ await session.delete(thread)
141
+ await session.flush()
142
+ return True
143
+ return False
144
+
145
+ async def find_summaries_by_user(
146
+ self, session: AsyncSession, user_id: int
147
+ ) -> list[AssistantThread]:
148
+ """Retrieve thread summaries (no messages) for a user."""
149
+ stmt = (
150
+ select(AssistantThread)
151
+ .where(AssistantThread.user_id == user_id)
152
+ .options(defer(AssistantThread.messages))
153
+ .order_by(AssistantThread.updated_at.desc())
154
+ )
155
+ result = await session.execute(stmt)
156
+ return list(result.scalars().all())
157
+
158
+
159
+ # Export instances
160
+ mcp_server_repo = MCPServerRepository()
161
+ system_prompt_repo = SystemPromptRepository()
162
+ thread_repo = ThreadRepository()
@@ -3,7 +3,8 @@ import logging
3
3
  from datetime import UTC, datetime, timedelta
4
4
  from typing import Final
5
5
 
6
- from appkit_assistant.backend.repositories import SystemPromptRepository
6
+ from appkit_assistant.backend.repositories import system_prompt_repo
7
+ from appkit_commons.database.session import get_asyncdb_session
7
8
 
8
9
  logger = logging.getLogger(__name__)
9
10
 
@@ -81,15 +82,24 @@ class SystemPromptCache:
81
82
  # Cache miss or expired - fetch from database
82
83
  logger.info("Cache miss - fetching latest prompt from database")
83
84
 
84
- latest_prompt = await SystemPromptRepository.get_latest()
85
+ async with get_asyncdb_session() as session:
86
+ latest_prompt = await system_prompt_repo.find_latest(session)
87
+
88
+ if latest_prompt is None:
89
+ # Raise inside to exit or handle outside
90
+ pass
91
+ else:
92
+ # Capture values while attached
93
+ prompt_text = latest_prompt.prompt
94
+ prompt_version = latest_prompt.version
85
95
 
86
96
  if latest_prompt is None:
87
97
  msg = "No system prompt found in database"
88
98
  logger.error(msg)
89
99
  raise ValueError(msg)
90
100
 
91
- self._cached_prompt = latest_prompt.prompt
92
- self._cached_version = latest_prompt.version
101
+ self._cached_prompt = prompt_text
102
+ self._cached_version = prompt_version
93
103
  self._cache_timestamp = datetime.now(UTC)
94
104
 
95
105
  logger.info(
@@ -130,7 +130,7 @@ class ValidationState(rx.State):
130
130
  @var_operation
131
131
  def json(obj: rx.Var, indent: int = 4) -> CustomVarOperationReturn[RETURN]:
132
132
  return var_operation_return(
133
- js_expression=f"JSON.stringify(JSON.parse({obj}), null, {indent})",
133
+ js_expression=f"JSON.stringify(JSON.parse({obj} || '{{}}'), null, {indent})",
134
134
  var_type=Any,
135
135
  )
136
136
 
@@ -9,8 +9,9 @@ import reflex as rx
9
9
 
10
10
  from appkit_assistant.backend.models import MCPServer
11
11
  from appkit_assistant.backend.repositories import (
12
- MCPServerRepository,
12
+ mcp_server_repo,
13
13
  )
14
+ from appkit_commons.database.session import get_asyncdb_session
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
@@ -29,7 +30,9 @@ class MCPServerState(rx.State):
29
30
  """
30
31
  self.loading = True
31
32
  try:
32
- self.servers = await MCPServerRepository.get_all()
33
+ async with get_asyncdb_session() as session:
34
+ servers = await mcp_server_repo.find_all_ordered_by_name(session)
35
+ self.servers = [MCPServer(**s.model_dump()) for s in servers]
33
36
  logger.debug("Loaded %d MCP servers", len(self.servers))
34
37
  except Exception as e:
35
38
  logger.error("Failed to load MCP servers: %s", e)
@@ -50,7 +53,13 @@ class MCPServerState(rx.State):
50
53
  async def get_server(self, server_id: int) -> None:
51
54
  """Get a specific MCP server by ID."""
52
55
  try:
53
- self.current_server = await MCPServerRepository.get_by_id(server_id)
56
+ async with get_asyncdb_session() as session:
57
+ server = await mcp_server_repo.find_by_id(session, server_id)
58
+ if server:
59
+ self.current_server = MCPServer(**server.model_dump())
60
+ else:
61
+ self.current_server = None
62
+
54
63
  if not self.current_server:
55
64
  logger.warning("MCP server with ID %d not found", server_id)
56
65
  except Exception as e:
@@ -64,7 +73,7 @@ class MCPServerState(rx.State):
64
73
  """Add a new MCP server."""
65
74
  try:
66
75
  headers = self._parse_headers_from_form(form_data)
67
- server = await MCPServerRepository.create(
76
+ server_entity = MCPServer(
68
77
  name=form_data["name"],
69
78
  url=form_data["url"],
70
79
  headers=headers,
@@ -72,12 +81,17 @@ class MCPServerState(rx.State):
72
81
  prompt=form_data.get("prompt") or None,
73
82
  )
74
83
 
84
+ async with get_asyncdb_session() as session:
85
+ server = await mcp_server_repo.save(session, server_entity)
86
+ # Ensure we have the name before session closes if used later
87
+ server_name = server.name
88
+
75
89
  await self.load_servers()
76
90
  yield rx.toast.info(
77
91
  "MCP Server {} wurde hinzugefügt.".format(form_data["name"]),
78
92
  position="top-right",
79
93
  )
80
- logger.debug("Added MCP server: %s", server.name)
94
+ logger.debug("Added MCP server: %s", server_name)
81
95
 
82
96
  except ValueError as e:
83
97
  logger.error("Invalid form data for MCP server: %s", e)
@@ -105,22 +119,34 @@ class MCPServerState(rx.State):
105
119
 
106
120
  try:
107
121
  headers = self._parse_headers_from_form(form_data)
108
- updated_server = await MCPServerRepository.update(
109
- server_id=self.current_server.id,
110
- name=form_data["name"],
111
- url=form_data["url"],
112
- headers=headers,
113
- description=form_data.get("description") or None,
114
- prompt=form_data.get("prompt") or None,
115
- )
122
+ updated_name = ""
116
123
 
117
- if updated_server:
124
+ async with get_asyncdb_session() as session:
125
+ # Re-fetch server to ensure we have the latest and bound to session
126
+ existing_server = await mcp_server_repo.find_by_id(
127
+ session, self.current_server.id
128
+ )
129
+
130
+ updated_server = None
131
+ if existing_server:
132
+ existing_server.name = form_data["name"]
133
+ existing_server.url = form_data["url"]
134
+ existing_server.headers = headers
135
+ existing_server.description = form_data.get("description") or None
136
+ existing_server.prompt = form_data.get("prompt") or None
137
+
138
+ updated_server = await mcp_server_repo.save(
139
+ session, existing_server
140
+ )
141
+ updated_name = updated_server.name
142
+
143
+ if updated_name:
118
144
  await self.load_servers()
119
145
  yield rx.toast.info(
120
146
  "MCP Server {} wurde aktualisiert.".format(form_data["name"]),
121
147
  position="top-right",
122
148
  )
123
- logger.debug("Updated MCP server: %s", updated_server.name)
149
+ logger.debug("Updated MCP server: %s", updated_name)
124
150
  else:
125
151
  yield rx.toast.error(
126
152
  "MCP Server konnte nicht gefunden werden.",
@@ -143,19 +169,20 @@ class MCPServerState(rx.State):
143
169
  async def delete_server(self, server_id: int) -> AsyncGenerator[Any, Any]:
144
170
  """Delete an MCP server."""
145
171
  try:
146
- # Get server name for the success message
147
- server = await MCPServerRepository.get_by_id(server_id)
148
- if not server:
149
- yield rx.toast.error(
150
- "MCP Server nicht gefunden.",
151
- position="top-right",
152
- )
153
- return
154
-
155
- server_name = server.name
156
-
157
- # Delete server using repository
158
- success = await MCPServerRepository.delete(server_id)
172
+ async with get_asyncdb_session() as session:
173
+ # Get server name for the success message
174
+ server = await mcp_server_repo.find_by_id(session, server_id)
175
+ if not server:
176
+ yield rx.toast.error(
177
+ "MCP Server nicht gefunden.",
178
+ position="top-right",
179
+ )
180
+ return
181
+
182
+ server_name = server.name
183
+
184
+ # Delete server using repository
185
+ success = await mcp_server_repo.delete_by_id(session, server_id)
159
186
 
160
187
  if success:
161
188
  await self.load_servers()
@@ -5,8 +5,9 @@ from typing import Any, Final
5
5
  import reflex as rx
6
6
  from reflex.state import State
7
7
 
8
- from appkit_assistant.backend.repositories import SystemPromptRepository
8
+ from appkit_assistant.backend.repositories import system_prompt_repo
9
9
  from appkit_assistant.backend.system_prompt_cache import invalidate_prompt_cache
10
+ from appkit_commons.database.session import get_asyncdb_session
10
11
  from appkit_user.authentication.states import UserSession
11
12
 
12
13
  logger = logging.getLogger(__name__)
@@ -32,31 +33,41 @@ class SystemPromptState(State):
32
33
  self.is_loading = True
33
34
  self.error_message = ""
34
35
  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
36
+ async with get_asyncdb_session() as session:
37
+ prompts = await system_prompt_repo.find_all_ordered_by_version_desc(
38
+ session
39
+ )
40
+
41
+ self.versions = [
42
+ {
43
+ "value": str(p.version),
44
+ "label": (
45
+ f"Version {p.version} - "
46
+ f"{p.created_at.strftime('%d.%m.%Y %H:%M')}"
47
+ ),
48
+ }
49
+ for p in prompts
50
+ ]
51
+
52
+ self.prompt_map = {str(p.version): p.prompt for p in prompts}
53
+
54
+ latest_prompt = None
55
+ if prompts:
56
+ latest_prompt = prompts[0]
57
+ # Access attributes to ensure they are loaded/available
58
+ self.selected_version_id = latest_prompt.version
59
+ latest_prompt_text = latest_prompt.prompt
60
+ else:
61
+ self.selected_version_id = 0
62
+ latest_prompt_text = None
52
63
 
64
+ if latest_prompt_text is not None:
53
65
  if not self.current_prompt:
54
- self.current_prompt = latest.prompt
55
- self.last_saved_prompt = latest.prompt
66
+ self.current_prompt = latest_prompt_text
67
+ self.last_saved_prompt = latest_prompt_text
56
68
  else:
57
- self.last_saved_prompt = latest.prompt
69
+ self.last_saved_prompt = latest_prompt_text
58
70
  else:
59
- self.selected_version_id = 0
60
71
  if not self.current_prompt:
61
72
  self.current_prompt = ""
62
73
  self.last_saved_prompt = self.current_prompt
@@ -93,10 +104,12 @@ class SystemPromptState(State):
93
104
  user_session: UserSession = await self.get_state(UserSession)
94
105
  user_id = user_session.user_id
95
106
 
96
- await SystemPromptRepository.create(
97
- prompt=self.current_prompt,
98
- user_id=user_id,
99
- )
107
+ async with get_asyncdb_session() as session:
108
+ await system_prompt_repo.create_next_version(
109
+ session,
110
+ prompt=self.current_prompt,
111
+ user_id=user_id,
112
+ )
100
113
 
101
114
  self.last_saved_prompt = self.current_prompt
102
115
 
@@ -124,7 +137,18 @@ class SystemPromptState(State):
124
137
  self.is_loading = True
125
138
  self.error_message = ""
126
139
  try:
127
- success = await SystemPromptRepository.delete(self.selected_version_id)
140
+ async with get_asyncdb_session() as session:
141
+ if self.selected_version_id:
142
+ prompt = await system_prompt_repo.find_by_id(
143
+ session, self.selected_version_id
144
+ )
145
+ if prompt:
146
+ success = await system_prompt_repo.delete(session, prompt)
147
+ else:
148
+ success = False
149
+ else:
150
+ success = False
151
+
128
152
  if success:
129
153
  self.selected_version_id = 0
130
154
 
@@ -15,8 +15,9 @@ from typing import TYPE_CHECKING, Any
15
15
 
16
16
  import reflex as rx
17
17
 
18
- from appkit_assistant.backend.models import ThreadModel
19
- from appkit_assistant.backend.repositories import ThreadRepository
18
+ from appkit_assistant.backend.models import ThreadModel, ThreadStatus
19
+ from appkit_assistant.backend.repositories import thread_repo
20
+ from appkit_commons.database.session import get_asyncdb_session
20
21
  from appkit_user.authentication.states import UserSession
21
22
 
22
23
  if TYPE_CHECKING:
@@ -124,7 +125,24 @@ class ThreadListState(rx.State):
124
125
 
125
126
  # Fetch threads from database
126
127
  try:
127
- threads = await ThreadRepository.get_summaries_by_user(user_id)
128
+ async with get_asyncdb_session() as session:
129
+ thread_entities = await thread_repo.find_summaries_by_user(
130
+ session, user_id
131
+ )
132
+
133
+ # Convert entities to models inside the session context
134
+ threads = [
135
+ ThreadModel(
136
+ thread_id=t.thread_id,
137
+ title=t.title,
138
+ state=ThreadStatus(t.state),
139
+ ai_model=t.ai_model,
140
+ active=t.active,
141
+ messages=[],
142
+ )
143
+ for t in thread_entities
144
+ ]
145
+
128
146
  async with self:
129
147
  self.threads = threads
130
148
  self._initialized = True
@@ -208,7 +226,10 @@ class ThreadListState(rx.State):
208
226
 
209
227
  try:
210
228
  # Delete from database
211
- await ThreadRepository.delete_thread(thread_id, user_id)
229
+ async with get_asyncdb_session() as session:
230
+ await thread_repo.delete_by_thread_id_and_user(
231
+ session, thread_id, user_id
232
+ )
212
233
 
213
234
  async with self:
214
235
  # Remove from list
@@ -22,6 +22,7 @@ from pydantic import BaseModel
22
22
  from appkit_assistant.backend.model_manager import ModelManager
23
23
  from appkit_assistant.backend.models import (
24
24
  AIModel,
25
+ AssistantThread,
25
26
  Chunk,
26
27
  ChunkType,
27
28
  MCPServer,
@@ -31,8 +32,9 @@ from appkit_assistant.backend.models import (
31
32
  ThreadModel,
32
33
  ThreadStatus,
33
34
  )
34
- from appkit_assistant.backend.repositories import MCPServerRepository, ThreadRepository
35
+ from appkit_assistant.backend.repositories import mcp_server_repo, thread_repo
35
36
  from appkit_assistant.state.thread_list_state import ThreadListState
37
+ from appkit_commons.database.session import get_asyncdb_session
36
38
  from appkit_user.authentication.states import UserSession
37
39
 
38
40
  logger = logging.getLogger(__name__)
@@ -268,20 +270,41 @@ class ThreadState(rx.State):
268
270
  return
269
271
 
270
272
  try:
271
- full_thread = await ThreadRepository.get_thread_by_id(thread_id, user_id)
273
+ async with get_asyncdb_session() as session:
274
+ thread_entity = await thread_repo.find_by_thread_id_and_user(
275
+ session, thread_id, user_id
276
+ )
272
277
 
273
- if not full_thread:
274
- logger.warning("Thread %s not found in database", thread_id)
275
- async with self:
276
- threadlist_state: ThreadListState = await self.get_state(
277
- ThreadListState
278
+ if not thread_entity:
279
+ logger.warning("Thread %s not found in database", thread_id)
280
+ # We can't access self in here easily to clear loading state unless we break out
281
+ # but we can check thread_entity after context
282
+
283
+ # Convert to ThreadModel if found
284
+ full_thread = None
285
+ if thread_entity:
286
+ full_thread = ThreadModel(
287
+ thread_id=thread_entity.thread_id,
288
+ title=thread_entity.title,
289
+ state=ThreadStatus(thread_entity.state),
290
+ ai_model=thread_entity.ai_model,
291
+ active=thread_entity.active,
292
+ messages=[Message(**m) for m in thread_entity.messages],
278
293
  )
279
- threadlist_state.loading_thread_id = ""
280
- return
294
+
295
+ if not full_thread:
296
+ if not thread_entity: # it was not found
297
+ async with self:
298
+ threadlist_state: ThreadListState = await self.get_state(
299
+ ThreadListState
300
+ )
301
+ threadlist_state.loading_thread_id = ""
302
+ return
281
303
 
282
304
  # Mark all messages as done (loaded from DB)
283
- for msg in full_thread.messages:
284
- msg.done = True
305
+ if full_thread:
306
+ for msg in full_thread.messages:
307
+ msg.done = True
285
308
 
286
309
  async with self:
287
310
  # Update self with loaded thread
@@ -355,7 +378,10 @@ class ThreadState(rx.State):
355
378
  @rx.event
356
379
  async def load_mcp_servers(self) -> None:
357
380
  """Load available MCP servers from the database."""
358
- self.available_mcp_servers = await MCPServerRepository.get_all()
381
+ async with get_asyncdb_session() as session:
382
+ servers = await mcp_server_repo.find_all_ordered_by_name(session)
383
+ # Create detached copies
384
+ self.available_mcp_servers = [MCPServer(**s.model_dump()) for s in servers]
359
385
 
360
386
  @rx.event
361
387
  def toogle_tools_modal(self, show: bool) -> None:
@@ -598,7 +624,40 @@ class ThreadState(rx.State):
598
624
 
599
625
  if user_id:
600
626
  try:
601
- await ThreadRepository.save_thread(self._thread, user_id)
627
+ # Prepare entity data
628
+ messages_dict = [m.dict() for m in self._thread.messages]
629
+
630
+ async with get_asyncdb_session() as session:
631
+ # Check if exists
632
+ existing = await thread_repo.find_by_thread_id_and_user(
633
+ session, self._thread.thread_id, user_id
634
+ )
635
+
636
+ if existing:
637
+ existing.title = self._thread.title
638
+ existing.state = (
639
+ self._thread.state.value
640
+ if hasattr(self._thread.state, "value")
641
+ else self._thread.state
642
+ )
643
+ existing.ai_model = self._thread.ai_model
644
+ existing.active = self._thread.active
645
+ existing.messages = messages_dict
646
+ await thread_repo.save(session, existing)
647
+ else:
648
+ new_thread = AssistantThread(
649
+ thread_id=self._thread.thread_id,
650
+ user_id=user_id,
651
+ title=self._thread.title,
652
+ state=self._thread.state.value
653
+ if hasattr(self._thread.state, "value")
654
+ else self._thread.state,
655
+ ai_model=self._thread.ai_model,
656
+ active=self._thread.active,
657
+ messages=messages_dict,
658
+ )
659
+ await thread_repo.save(session, new_thread)
660
+
602
661
  logger.debug("Saved thread to DB: %s", self._thread.thread_id)
603
662
  except Exception as e:
604
663
  logger.error("Error saving thread %s: %s", self._thread.thread_id, e)
@@ -1,323 +0,0 @@
1
- """Repository for MCP server data access operations."""
2
-
3
- import logging
4
- from datetime import UTC, datetime
5
-
6
- import reflex as rx
7
- from sqlalchemy.orm import defer
8
-
9
- from appkit_assistant.backend.models import (
10
- AssistantThread,
11
- MCPServer,
12
- Message,
13
- SystemPrompt,
14
- ThreadModel,
15
- ThreadStatus,
16
- )
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
-
21
- class MCPServerRepository:
22
- """Repository class for MCP server database operations."""
23
-
24
- @staticmethod
25
- async def get_all() -> list[MCPServer]:
26
- """Retrieve all MCP servers ordered by name."""
27
- async with rx.asession() as session:
28
- result = await session.exec(MCPServer.select().order_by(MCPServer.name))
29
- return result.all()
30
-
31
- @staticmethod
32
- async def get_by_id(server_id: int) -> MCPServer | None:
33
- """Retrieve an MCP server by ID."""
34
- async with rx.asession() as session:
35
- result = await session.exec(
36
- MCPServer.select().where(MCPServer.id == server_id)
37
- )
38
- return result.first()
39
-
40
- @staticmethod
41
- async def create(
42
- name: str,
43
- url: str,
44
- headers: str,
45
- description: str | None = None,
46
- prompt: str | None = None,
47
- ) -> MCPServer:
48
- """Create a new MCP server."""
49
- async with rx.asession() as session:
50
- server = MCPServer(
51
- name=name,
52
- url=url,
53
- headers=headers,
54
- description=description,
55
- prompt=prompt,
56
- )
57
- session.add(server)
58
- await session.commit()
59
- await session.refresh(server)
60
- logger.debug("Created MCP server: %s", name)
61
- return server
62
-
63
- @staticmethod
64
- async def update(
65
- server_id: int,
66
- name: str,
67
- url: str,
68
- headers: str,
69
- description: str | None = None,
70
- prompt: str | None = None,
71
- ) -> MCPServer | None:
72
- """Update an existing MCP server."""
73
- async with rx.asession() as session:
74
- result = await session.exec(
75
- MCPServer.select().where(MCPServer.id == server_id)
76
- )
77
- server = result.first()
78
- if server:
79
- server.name = name
80
- server.url = url
81
- server.headers = headers
82
- server.description = description
83
- server.prompt = prompt
84
- await session.commit()
85
- await session.refresh(server)
86
- logger.debug("Updated MCP server: %s", name)
87
- return server
88
- logger.warning("MCP server with ID %s not found for update", server_id)
89
- return None
90
-
91
- @staticmethod
92
- async def delete(server_id: int) -> bool:
93
- """Delete an MCP server by ID."""
94
- async with rx.asession() as session:
95
- result = await session.exec(
96
- MCPServer.select().where(MCPServer.id == server_id)
97
- )
98
- server = result.first()
99
- if server:
100
- await session.delete(server)
101
- await session.commit()
102
- logger.debug("Deleted MCP server: %s", server.name)
103
- return True
104
- logger.warning("MCP server with ID %s not found for deletion", server_id)
105
- return False
106
-
107
-
108
- class SystemPromptRepository:
109
- """Repository class for system prompt database operations.
110
-
111
- Implements append-only versioning with full CRUD capabilities.
112
- """
113
-
114
- @staticmethod
115
- async def get_all() -> list[SystemPrompt]:
116
- """Retrieve all system prompt versions ordered by version descending."""
117
- async with rx.asession() as session:
118
- result = await session.exec(
119
- SystemPrompt.select().order_by(SystemPrompt.version.desc())
120
- )
121
- return result.all()
122
-
123
- @staticmethod
124
- async def get_latest() -> SystemPrompt | None:
125
- """Retrieve the latest system prompt version."""
126
- async with rx.asession() as session:
127
- result = await session.exec(
128
- SystemPrompt.select().order_by(SystemPrompt.version.desc()).limit(1)
129
- )
130
- return result.first()
131
-
132
- @staticmethod
133
- async def get_by_id(prompt_id: int) -> SystemPrompt | None:
134
- """Retrieve a system prompt by ID."""
135
- async with rx.asession() as session:
136
- result = await session.exec(
137
- SystemPrompt.select().where(SystemPrompt.id == prompt_id)
138
- )
139
- return result.first()
140
-
141
- @staticmethod
142
- async def create(prompt: str, user_id: int) -> SystemPrompt:
143
- """Neue System Prompt Version anlegen.
144
-
145
- Version ist fortlaufende Ganzzahl, beginnend bei 1.
146
- """
147
- async with rx.asession() as session:
148
- result = await session.exec(
149
- SystemPrompt.select().order_by(SystemPrompt.version.desc()).limit(1)
150
- )
151
- latest = result.first()
152
- next_version = (latest.version + 1) if latest else 1
153
-
154
- name = f"Version {next_version}"
155
-
156
- system_prompt = SystemPrompt(
157
- name=name,
158
- prompt=prompt,
159
- version=next_version,
160
- user_id=user_id,
161
- created_at=datetime.now(UTC),
162
- )
163
- session.add(system_prompt)
164
- await session.commit()
165
- await session.refresh(system_prompt)
166
-
167
- logger.info(
168
- "Created system prompt version %s for user %s",
169
- next_version,
170
- user_id,
171
- )
172
- return system_prompt
173
-
174
- @staticmethod
175
- async def delete(prompt_id: int) -> bool:
176
- """Delete a system prompt version by ID."""
177
- async with rx.asession() as session:
178
- result = await session.exec(
179
- SystemPrompt.select().where(SystemPrompt.id == prompt_id)
180
- )
181
- prompt = result.first()
182
- if prompt:
183
- await session.delete(prompt)
184
- await session.commit()
185
- logger.info("Deleted system prompt version: %s", prompt.version)
186
- return True
187
- logger.warning(
188
- "System prompt with ID %s not found for deletion",
189
- prompt_id,
190
- )
191
- return False
192
-
193
-
194
- class ThreadRepository:
195
- """Repository class for Thread database operations."""
196
-
197
- @staticmethod
198
- async def get_by_user(user_id: int) -> list[ThreadModel]:
199
- """Retrieve all threads for a user."""
200
- async with rx.asession() as session:
201
- result = await session.exec(
202
- AssistantThread.select()
203
- .where(AssistantThread.user_id == user_id)
204
- .order_by(AssistantThread.updated_at.desc())
205
- )
206
- threads = result.all()
207
- return [
208
- ThreadModel(
209
- thread_id=t.thread_id,
210
- title=t.title,
211
- state=ThreadStatus(t.state),
212
- ai_model=t.ai_model,
213
- active=t.active,
214
- messages=[Message(**m) for m in t.messages],
215
- )
216
- for t in threads
217
- ]
218
-
219
- @staticmethod
220
- async def save_thread(thread: ThreadModel, user_id: int) -> None:
221
- """Save or update a thread."""
222
- async with rx.asession() as session:
223
- result = await session.exec(
224
- AssistantThread.select().where(
225
- AssistantThread.thread_id == thread.thread_id
226
- )
227
- )
228
- db_thread = result.first()
229
-
230
- messages_dict = [m.dict() for m in thread.messages]
231
-
232
- if db_thread:
233
- # Ensure user owns the thread or handle shared threads logic if needed
234
- # For now, we assume thread_id is unique enough,
235
- # but checking user_id is safer
236
- if db_thread.user_id != user_id:
237
- logger.warning(
238
- "User %s tried to update thread %s belonging to user %s",
239
- user_id,
240
- thread.thread_id,
241
- db_thread.user_id,
242
- )
243
- return
244
-
245
- db_thread.title = thread.title
246
- db_thread.state = thread.state.value
247
- db_thread.ai_model = thread.ai_model
248
- db_thread.active = thread.active
249
- db_thread.messages = messages_dict
250
- session.add(db_thread)
251
- else:
252
- db_thread = AssistantThread(
253
- thread_id=thread.thread_id,
254
- user_id=user_id,
255
- title=thread.title,
256
- state=thread.state.value,
257
- ai_model=thread.ai_model,
258
- active=thread.active,
259
- messages=messages_dict,
260
- )
261
- session.add(db_thread)
262
-
263
- await session.commit()
264
-
265
- @staticmethod
266
- async def delete_thread(thread_id: str, user_id: int) -> None:
267
- """Delete a thread."""
268
- async with rx.asession() as session:
269
- result = await session.exec(
270
- AssistantThread.select().where(
271
- AssistantThread.thread_id == thread_id,
272
- AssistantThread.user_id == user_id,
273
- )
274
- )
275
- thread = result.first()
276
- if thread:
277
- await session.delete(thread)
278
- await session.commit()
279
-
280
- @staticmethod
281
- async def get_summaries_by_user(user_id: int) -> list[ThreadModel]:
282
- """Retrieve thread summaries (no messages) for a user."""
283
- async with rx.asession() as session:
284
- result = await session.exec(
285
- AssistantThread.select()
286
- .where(AssistantThread.user_id == user_id)
287
- .options(defer(AssistantThread.messages))
288
- .order_by(AssistantThread.updated_at.desc())
289
- )
290
- threads = result.all()
291
- return [
292
- ThreadModel(
293
- thread_id=t.thread_id,
294
- title=t.title,
295
- state=ThreadStatus(t.state),
296
- ai_model=t.ai_model,
297
- active=t.active,
298
- messages=[], # Empty messages for summary
299
- )
300
- for t in threads
301
- ]
302
-
303
- @staticmethod
304
- async def get_thread_by_id(thread_id: str, user_id: int) -> ThreadModel | None:
305
- """Retrieve a full thread by ID."""
306
- async with rx.asession() as session:
307
- result = await session.exec(
308
- AssistantThread.select().where(
309
- AssistantThread.thread_id == thread_id,
310
- AssistantThread.user_id == user_id,
311
- )
312
- )
313
- t = result.first()
314
- if not t:
315
- return None
316
- return ThreadModel(
317
- thread_id=t.thread_id,
318
- title=t.title,
319
- state=ThreadStatus(t.state),
320
- ai_model=t.ai_model,
321
- active=t.active,
322
- messages=[Message(**m) for m in t.messages],
323
- )