appkit-assistant 0.8.0__tar.gz → 0.10.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 (36) hide show
  1. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/PKG-INFO +2 -2
  2. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/pyproject.toml +8 -8
  3. appkit_assistant-0.10.0/src/appkit_assistant/backend/models.py +196 -0
  4. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/processors/openai_responses_processor.py +16 -11
  5. appkit_assistant-0.10.0/src/appkit_assistant/backend/repositories.py +323 -0
  6. appkit_assistant-0.10.0/src/appkit_assistant/backend/system_prompt_cache.py +161 -0
  7. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/components/__init__.py +2 -4
  8. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/components/mcp_server_dialogs.py +7 -2
  9. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/components/message.py +3 -3
  10. appkit_assistant-0.10.0/src/appkit_assistant/components/system_prompt_editor.py +78 -0
  11. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/components/thread.py +8 -16
  12. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/components/threadlist.py +42 -29
  13. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/components/tools_modal.py +1 -1
  14. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/configuration.py +1 -0
  15. appkit_assistant-0.10.0/src/appkit_assistant/state/system_prompt_state.py +179 -0
  16. appkit_assistant-0.10.0/src/appkit_assistant/state/thread_list_state.py +271 -0
  17. appkit_assistant-0.10.0/src/appkit_assistant/state/thread_state.py +791 -0
  18. appkit_assistant-0.8.0/src/appkit_assistant/backend/models.py +0 -105
  19. appkit_assistant-0.8.0/src/appkit_assistant/backend/repositories.py +0 -96
  20. appkit_assistant-0.8.0/src/appkit_assistant/state/thread_state.py +0 -874
  21. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/.gitignore +0 -0
  22. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/README.md +0 -0
  23. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/docs/assistant.png +0 -0
  24. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/model_manager.py +0 -0
  25. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/processor.py +0 -0
  26. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/processors/ai_models.py +0 -0
  27. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/processors/knowledgeai_processor.py +0 -0
  28. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/processors/lorem_ipsum_processor.py +0 -0
  29. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/processors/openai_base.py +0 -0
  30. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/processors/openai_chat_completion_processor.py +0 -0
  31. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/processors/perplexity_processor.py +0 -0
  32. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/backend/system_prompt.py +0 -0
  33. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/components/composer.py +0 -0
  34. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/components/composer_key_handler.py +0 -0
  35. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/components/mcp_server_table.py +0 -0
  36. {appkit_assistant-0.8.0 → appkit_assistant-0.10.0}/src/appkit_assistant/state/mcp_server_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appkit-assistant
3
- Version: 0.8.0
3
+ Version: 0.10.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
@@ -20,7 +20,7 @@ Requires-Dist: appkit-commons
20
20
  Requires-Dist: appkit-mantine
21
21
  Requires-Dist: appkit-ui
22
22
  Requires-Dist: openai>=2.3.0
23
- Requires-Dist: reflex>=0.8.20
23
+ Requires-Dist: reflex>=0.8.22
24
24
  Description-Content-Type: text/markdown
25
25
 
26
26
  # appkit-assistant
@@ -1,6 +1,13 @@
1
1
  [project]
2
+ dependencies = [
3
+ "appkit-commons",
4
+ "appkit-mantine",
5
+ "appkit-ui",
6
+ "openai>=2.3.0",
7
+ "reflex>=0.8.22",
8
+ ]
2
9
  name = "appkit-assistant"
3
- version = "0.8.0"
10
+ version = "0.10.0"
4
11
  description = "Add your description here"
5
12
  readme = "README.md"
6
13
  authors = [{ name = "Jens Rehpöhler" }]
@@ -21,13 +28,6 @@ classifiers = [
21
28
  "Topic :: Software Development :: Libraries :: Python Modules",
22
29
  "Topic :: Software Development :: User Interfaces",
23
30
  ]
24
- dependencies = [
25
- "appkit-commons",
26
- "appkit-mantine",
27
- "appkit-ui",
28
- "openai>=2.3.0",
29
- "reflex>=0.8.20",
30
- ]
31
31
 
32
32
  [project.urls]
33
33
  Homepage = "https://github.com/jenreh/appkit"
@@ -0,0 +1,196 @@
1
+ import json
2
+ from datetime import UTC, datetime
3
+ from enum import StrEnum
4
+ from typing import Any
5
+
6
+ import reflex as rx
7
+ from pydantic import BaseModel
8
+ from sqlalchemy.sql import func
9
+ from sqlmodel import Column, DateTime, Field
10
+
11
+ from appkit_commons.database.configuration import DatabaseConfig
12
+ from appkit_commons.database.entities import EncryptedString
13
+ from appkit_commons.registry import service_registry
14
+
15
+ db_config = service_registry().get(DatabaseConfig)
16
+ SECRET_VALUE = db_config.encryption_key.get_secret_value()
17
+
18
+
19
+ class EncryptedJSON(EncryptedString):
20
+ """Custom type for storing encrypted JSON data."""
21
+
22
+ def process_bind_param(self, value: Any, dialect: Any) -> str | None:
23
+ if value is not None:
24
+ value = json.dumps(value)
25
+ return super().process_bind_param(value, dialect)
26
+
27
+ def process_result_value(self, value: Any, dialect: Any) -> Any | None:
28
+ value = super().process_result_value(value, dialect)
29
+ if value is not None:
30
+ return json.loads(value)
31
+ return value
32
+
33
+
34
+ class ChunkType(StrEnum):
35
+ """Enum for chunk types."""
36
+
37
+ TEXT = "text" # default
38
+ ANNOTATION = "annotation" # for text annotations
39
+ IMAGE = "image"
40
+ IMAGE_PARTIAL = "image_partial" # for streaming image generation
41
+ THINKING = "thinking" # when the model is "thinking" / reasoning
42
+ THINKING_RESULT = "thinking_result" # when the "thinking" is done
43
+ ACTION = "action" # when the user needs to take action
44
+ TOOL_RESULT = "tool_result" # result from a tool
45
+ TOOL_CALL = "tool_call" # calling a tool
46
+ COMPLETION = "completion" # when response generation is complete
47
+ ERROR = "error" # when an error occurs
48
+ LIFECYCLE = "lifecycle"
49
+
50
+
51
+ class Chunk(BaseModel):
52
+ """Model for text chunks."""
53
+
54
+ type: ChunkType
55
+ text: str
56
+ chunk_metadata: dict[str, str] = {}
57
+
58
+
59
+ class ThreadStatus(StrEnum):
60
+ """Enum for thread status."""
61
+
62
+ NEW = "new"
63
+ ACTIVE = "active"
64
+ IDLE = "idle"
65
+ WAITING = "waiting"
66
+ ERROR = "error"
67
+ DELETED = "deleted"
68
+ ARCHIVED = "archived"
69
+
70
+
71
+ class MessageType(StrEnum):
72
+ """Enum for message types."""
73
+
74
+ HUMAN = "human"
75
+ SYSTEM = "system"
76
+ ASSISTANT = "assistant"
77
+ TOOL_USE = "tool_use"
78
+ ERROR = "error"
79
+ INFO = "info"
80
+ WARNING = "warning"
81
+
82
+
83
+ class Message(BaseModel):
84
+ text: str
85
+ editable: bool = False
86
+ type: MessageType
87
+ done: bool = False
88
+
89
+
90
+ class AIModel(BaseModel):
91
+ id: str
92
+ text: str
93
+ icon: str = "codesandbox"
94
+ stream: bool = False
95
+ tenant_key: str = ""
96
+ project_id: int = 0
97
+ model: str = "default"
98
+ temperature: float = 0.05
99
+ supports_tools: bool = False
100
+ supports_attachments: bool = False
101
+ keywords: list[str] = []
102
+ disabled: bool = False
103
+
104
+
105
+ class Suggestion(BaseModel):
106
+ prompt: str
107
+ icon: str = ""
108
+
109
+
110
+ class ThreadModel(BaseModel):
111
+ thread_id: str
112
+ title: str = ""
113
+ active: bool = False
114
+ state: ThreadStatus = ThreadStatus.NEW
115
+ prompt: str | None = ""
116
+ messages: list[Message] = []
117
+ ai_model: str = ""
118
+
119
+
120
+ class MCPAuthType(StrEnum):
121
+ """Enum for MCP server authentication types."""
122
+
123
+ NONE = "none"
124
+ API_KEY = "api_key"
125
+ OAUTH_DISCOVERY = "oauth_discovery"
126
+
127
+
128
+ class MCPServer(rx.Model, table=True):
129
+ """Model for MCP (Model Context Protocol) server configuration."""
130
+
131
+ __tablename__ = "assistant_mcp_servers"
132
+
133
+ id: int | None = Field(default=None, primary_key=True)
134
+ name: str = Field(unique=True, max_length=100, nullable=False)
135
+ description: str = Field(default="", max_length=255, nullable=True)
136
+ url: str = Field(nullable=False)
137
+ headers: str = Field(nullable=False, sa_type=EncryptedString)
138
+ prompt: str = Field(default="", max_length=2000, nullable=True)
139
+
140
+ # Authentication type
141
+ auth_type: str = Field(default=MCPAuthType.NONE, nullable=False)
142
+
143
+ # Optional discovery URL override
144
+ discovery_url: str | None = Field(default=None, nullable=True)
145
+
146
+ # Cached OAuth/Discovery metadata (read-only for user mostly)
147
+ oauth_issuer: str | None = Field(default=None, nullable=True)
148
+ oauth_authorize_url: str | None = Field(default=None, nullable=True)
149
+ oauth_token_url: str | None = Field(default=None, nullable=True)
150
+ oauth_scopes: str | None = Field(
151
+ default=None, nullable=True
152
+ ) # Space separated scopes
153
+
154
+ # Timestamp when discovery was last successfully run
155
+ oauth_discovered_at: datetime | None = Field(
156
+ default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
157
+ )
158
+
159
+
160
+ class SystemPrompt(rx.Model, table=True):
161
+ """Model for system prompt versioning and management.
162
+
163
+ Each save creates a new immutable version. Supports up to 20,000 characters.
164
+ """
165
+
166
+ __tablename__ = "assistant_system_prompt"
167
+
168
+ id: int | None = Field(default=None, primary_key=True)
169
+ name: str = Field(max_length=200, nullable=False)
170
+ prompt: str = Field(max_length=20000, nullable=False)
171
+ version: int = Field(nullable=False)
172
+ user_id: int = Field(nullable=False)
173
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
174
+
175
+
176
+ class AssistantThread(rx.Model, table=True):
177
+ """Model for storing chat threads in the database."""
178
+
179
+ __tablename__ = "assistant_thread"
180
+
181
+ id: int | None = Field(default=None, primary_key=True)
182
+ thread_id: str = Field(unique=True, index=True, nullable=False)
183
+ user_id: int = Field(index=True, nullable=False)
184
+ title: str = Field(default="", nullable=False)
185
+ state: str = Field(default=ThreadStatus.NEW, nullable=False)
186
+ ai_model: str = Field(default="", nullable=False)
187
+ active: bool = Field(default=False, nullable=False)
188
+ messages: list[dict[str, Any]] = Field(default=[], sa_column=Column(EncryptedJSON))
189
+ created_at: datetime = Field(
190
+ default_factory=lambda: datetime.now(UTC),
191
+ sa_column=Column(DateTime(timezone=True)),
192
+ )
193
+ updated_at: datetime = Field(
194
+ default_factory=lambda: datetime.now(UTC),
195
+ sa_column=Column(DateTime(timezone=True), onupdate=func.now()),
196
+ )
@@ -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,8 +453,11 @@ 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(
457
- self, messages: list[Message], mcp_prompt: str = ""
456
+ async def _convert_messages_to_responses_format(
457
+ self,
458
+ messages: list[Message],
459
+ mcp_prompt: str = "",
460
+ use_system_prompt: bool = True,
458
461
  ) -> list[dict[str, Any]]:
459
462
  """Convert messages to the responses API input format.
460
463
 
@@ -471,13 +474,15 @@ class OpenAIResponsesProcessor(BaseOpenAIProcessor):
471
474
  else:
472
475
  mcp_prompt = ""
473
476
 
474
- system_text = SYSTEM_PROMPT.format(mcp_prompts=mcp_prompt)
475
- input_messages.append(
476
- {
477
- "role": "system",
478
- "content": [{"type": "input_text", "text": system_text}],
479
- }
480
- )
477
+ if use_system_prompt:
478
+ system_prompt_template = await get_system_prompt()
479
+ system_text = system_prompt_template.format(mcp_prompts=mcp_prompt)
480
+ input_messages.append(
481
+ {
482
+ "role": "system",
483
+ "content": [{"type": "input_text", "text": system_text}],
484
+ }
485
+ )
481
486
 
482
487
  # Add conversation messages
483
488
  for msg in messages:
@@ -0,0 +1,323 @@
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
+ )