mito-ai 0.1.50__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.
Files changed (205) hide show
  1. mito_ai/__init__.py +114 -0
  2. mito_ai/_version.py +4 -0
  3. mito_ai/anthropic_client.py +334 -0
  4. mito_ai/app_deploy/__init__.py +6 -0
  5. mito_ai/app_deploy/app_deploy_utils.py +44 -0
  6. mito_ai/app_deploy/handlers.py +345 -0
  7. mito_ai/app_deploy/models.py +98 -0
  8. mito_ai/app_manager/__init__.py +4 -0
  9. mito_ai/app_manager/handlers.py +167 -0
  10. mito_ai/app_manager/models.py +71 -0
  11. mito_ai/app_manager/utils.py +24 -0
  12. mito_ai/auth/README.md +18 -0
  13. mito_ai/auth/__init__.py +6 -0
  14. mito_ai/auth/handlers.py +96 -0
  15. mito_ai/auth/urls.py +13 -0
  16. mito_ai/chat_history/handlers.py +63 -0
  17. mito_ai/chat_history/urls.py +32 -0
  18. mito_ai/completions/completion_handlers/__init__.py +3 -0
  19. mito_ai/completions/completion_handlers/agent_auto_error_fixup_handler.py +59 -0
  20. mito_ai/completions/completion_handlers/agent_execution_handler.py +66 -0
  21. mito_ai/completions/completion_handlers/chat_completion_handler.py +141 -0
  22. mito_ai/completions/completion_handlers/code_explain_handler.py +113 -0
  23. mito_ai/completions/completion_handlers/completion_handler.py +42 -0
  24. mito_ai/completions/completion_handlers/inline_completer_handler.py +48 -0
  25. mito_ai/completions/completion_handlers/smart_debug_handler.py +160 -0
  26. mito_ai/completions/completion_handlers/utils.py +147 -0
  27. mito_ai/completions/handlers.py +415 -0
  28. mito_ai/completions/message_history.py +401 -0
  29. mito_ai/completions/models.py +404 -0
  30. mito_ai/completions/prompt_builders/__init__.py +3 -0
  31. mito_ai/completions/prompt_builders/agent_execution_prompt.py +57 -0
  32. mito_ai/completions/prompt_builders/agent_smart_debug_prompt.py +160 -0
  33. mito_ai/completions/prompt_builders/agent_system_message.py +472 -0
  34. mito_ai/completions/prompt_builders/chat_name_prompt.py +15 -0
  35. mito_ai/completions/prompt_builders/chat_prompt.py +116 -0
  36. mito_ai/completions/prompt_builders/chat_system_message.py +92 -0
  37. mito_ai/completions/prompt_builders/explain_code_prompt.py +32 -0
  38. mito_ai/completions/prompt_builders/inline_completer_prompt.py +197 -0
  39. mito_ai/completions/prompt_builders/prompt_constants.py +170 -0
  40. mito_ai/completions/prompt_builders/smart_debug_prompt.py +199 -0
  41. mito_ai/completions/prompt_builders/utils.py +84 -0
  42. mito_ai/completions/providers.py +284 -0
  43. mito_ai/constants.py +63 -0
  44. mito_ai/db/__init__.py +3 -0
  45. mito_ai/db/crawlers/__init__.py +6 -0
  46. mito_ai/db/crawlers/base_crawler.py +61 -0
  47. mito_ai/db/crawlers/constants.py +43 -0
  48. mito_ai/db/crawlers/snowflake.py +71 -0
  49. mito_ai/db/handlers.py +168 -0
  50. mito_ai/db/models.py +31 -0
  51. mito_ai/db/urls.py +34 -0
  52. mito_ai/db/utils.py +185 -0
  53. mito_ai/docker/mssql/compose.yml +37 -0
  54. mito_ai/docker/mssql/init/setup.sql +21 -0
  55. mito_ai/docker/mysql/compose.yml +18 -0
  56. mito_ai/docker/mysql/init/setup.sql +13 -0
  57. mito_ai/docker/oracle/compose.yml +17 -0
  58. mito_ai/docker/oracle/init/setup.sql +20 -0
  59. mito_ai/docker/postgres/compose.yml +17 -0
  60. mito_ai/docker/postgres/init/setup.sql +13 -0
  61. mito_ai/enterprise/__init__.py +3 -0
  62. mito_ai/enterprise/utils.py +15 -0
  63. mito_ai/file_uploads/__init__.py +3 -0
  64. mito_ai/file_uploads/handlers.py +248 -0
  65. mito_ai/file_uploads/urls.py +21 -0
  66. mito_ai/gemini_client.py +232 -0
  67. mito_ai/log/handlers.py +38 -0
  68. mito_ai/log/urls.py +21 -0
  69. mito_ai/logger.py +37 -0
  70. mito_ai/openai_client.py +382 -0
  71. mito_ai/path_utils.py +70 -0
  72. mito_ai/rules/handlers.py +44 -0
  73. mito_ai/rules/urls.py +22 -0
  74. mito_ai/rules/utils.py +56 -0
  75. mito_ai/settings/handlers.py +41 -0
  76. mito_ai/settings/urls.py +20 -0
  77. mito_ai/settings/utils.py +42 -0
  78. mito_ai/streamlit_conversion/agent_utils.py +37 -0
  79. mito_ai/streamlit_conversion/prompts/prompt_constants.py +172 -0
  80. mito_ai/streamlit_conversion/prompts/prompt_utils.py +10 -0
  81. mito_ai/streamlit_conversion/prompts/streamlit_app_creation_prompt.py +46 -0
  82. mito_ai/streamlit_conversion/prompts/streamlit_error_correction_prompt.py +28 -0
  83. mito_ai/streamlit_conversion/prompts/streamlit_finish_todo_prompt.py +45 -0
  84. mito_ai/streamlit_conversion/prompts/streamlit_system_prompt.py +56 -0
  85. mito_ai/streamlit_conversion/prompts/update_existing_app_prompt.py +50 -0
  86. mito_ai/streamlit_conversion/search_replace_utils.py +94 -0
  87. mito_ai/streamlit_conversion/streamlit_agent_handler.py +144 -0
  88. mito_ai/streamlit_conversion/streamlit_utils.py +85 -0
  89. mito_ai/streamlit_conversion/validate_streamlit_app.py +105 -0
  90. mito_ai/streamlit_preview/__init__.py +6 -0
  91. mito_ai/streamlit_preview/handlers.py +111 -0
  92. mito_ai/streamlit_preview/manager.py +152 -0
  93. mito_ai/streamlit_preview/urls.py +22 -0
  94. mito_ai/streamlit_preview/utils.py +29 -0
  95. mito_ai/tests/__init__.py +3 -0
  96. mito_ai/tests/chat_history/test_chat_history.py +211 -0
  97. mito_ai/tests/completions/completion_handlers_utils_test.py +190 -0
  98. mito_ai/tests/conftest.py +53 -0
  99. mito_ai/tests/create_agent_system_message_prompt_test.py +22 -0
  100. mito_ai/tests/data/prompt_lg.py +69 -0
  101. mito_ai/tests/data/prompt_sm.py +6 -0
  102. mito_ai/tests/data/prompt_xl.py +13 -0
  103. mito_ai/tests/data/stock_data.sqlite3 +0 -0
  104. mito_ai/tests/db/conftest.py +39 -0
  105. mito_ai/tests/db/connections_test.py +102 -0
  106. mito_ai/tests/db/mssql_test.py +29 -0
  107. mito_ai/tests/db/mysql_test.py +29 -0
  108. mito_ai/tests/db/oracle_test.py +29 -0
  109. mito_ai/tests/db/postgres_test.py +29 -0
  110. mito_ai/tests/db/schema_test.py +93 -0
  111. mito_ai/tests/db/sqlite_test.py +31 -0
  112. mito_ai/tests/db/test_db_constants.py +61 -0
  113. mito_ai/tests/deploy_app/test_app_deploy_utils.py +89 -0
  114. mito_ai/tests/file_uploads/__init__.py +2 -0
  115. mito_ai/tests/file_uploads/test_handlers.py +282 -0
  116. mito_ai/tests/message_history/test_generate_short_chat_name.py +120 -0
  117. mito_ai/tests/message_history/test_message_history_utils.py +469 -0
  118. mito_ai/tests/open_ai_utils_test.py +152 -0
  119. mito_ai/tests/performance_test.py +329 -0
  120. mito_ai/tests/providers/test_anthropic_client.py +447 -0
  121. mito_ai/tests/providers/test_azure.py +631 -0
  122. mito_ai/tests/providers/test_capabilities.py +120 -0
  123. mito_ai/tests/providers/test_gemini_client.py +195 -0
  124. mito_ai/tests/providers/test_mito_server_utils.py +448 -0
  125. mito_ai/tests/providers/test_model_resolution.py +130 -0
  126. mito_ai/tests/providers/test_openai_client.py +57 -0
  127. mito_ai/tests/providers/test_provider_completion_exception.py +66 -0
  128. mito_ai/tests/providers/test_provider_limits.py +42 -0
  129. mito_ai/tests/providers/test_providers.py +382 -0
  130. mito_ai/tests/providers/test_retry_logic.py +389 -0
  131. mito_ai/tests/providers/test_stream_mito_server_utils.py +140 -0
  132. mito_ai/tests/providers/utils.py +85 -0
  133. mito_ai/tests/rules/conftest.py +26 -0
  134. mito_ai/tests/rules/rules_test.py +117 -0
  135. mito_ai/tests/server_limits_test.py +406 -0
  136. mito_ai/tests/settings/conftest.py +26 -0
  137. mito_ai/tests/settings/settings_test.py +70 -0
  138. mito_ai/tests/settings/test_settings_constants.py +9 -0
  139. mito_ai/tests/streamlit_conversion/__init__.py +3 -0
  140. mito_ai/tests/streamlit_conversion/test_apply_search_replace.py +240 -0
  141. mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +246 -0
  142. mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +193 -0
  143. mito_ai/tests/streamlit_conversion/test_validate_streamlit_app.py +112 -0
  144. mito_ai/tests/streamlit_preview/test_streamlit_preview_handler.py +118 -0
  145. mito_ai/tests/streamlit_preview/test_streamlit_preview_manager.py +292 -0
  146. mito_ai/tests/test_constants.py +47 -0
  147. mito_ai/tests/test_telemetry.py +12 -0
  148. mito_ai/tests/user/__init__.py +2 -0
  149. mito_ai/tests/user/test_user.py +120 -0
  150. mito_ai/tests/utils/__init__.py +3 -0
  151. mito_ai/tests/utils/test_anthropic_utils.py +162 -0
  152. mito_ai/tests/utils/test_gemini_utils.py +98 -0
  153. mito_ai/tests/version_check_test.py +169 -0
  154. mito_ai/user/handlers.py +45 -0
  155. mito_ai/user/urls.py +21 -0
  156. mito_ai/utils/__init__.py +3 -0
  157. mito_ai/utils/anthropic_utils.py +168 -0
  158. mito_ai/utils/create.py +94 -0
  159. mito_ai/utils/db.py +74 -0
  160. mito_ai/utils/error_classes.py +42 -0
  161. mito_ai/utils/gemini_utils.py +133 -0
  162. mito_ai/utils/message_history_utils.py +87 -0
  163. mito_ai/utils/mito_server_utils.py +242 -0
  164. mito_ai/utils/open_ai_utils.py +200 -0
  165. mito_ai/utils/provider_utils.py +49 -0
  166. mito_ai/utils/schema.py +86 -0
  167. mito_ai/utils/server_limits.py +152 -0
  168. mito_ai/utils/telemetry_utils.py +480 -0
  169. mito_ai/utils/utils.py +89 -0
  170. mito_ai/utils/version_utils.py +94 -0
  171. mito_ai/utils/websocket_base.py +88 -0
  172. mito_ai/version_check.py +60 -0
  173. mito_ai-0.1.50.data/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +7 -0
  174. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/build_log.json +728 -0
  175. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/package.json +243 -0
  176. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +238 -0
  177. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +37 -0
  178. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.8f1845da6bf2b128c049.js +21602 -0
  179. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.8f1845da6bf2b128c049.js.map +1 -0
  180. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js +198 -0
  181. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js.map +1 -0
  182. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.78d3ccb73e7ca1da3aae.js +619 -0
  183. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.78d3ccb73e7ca1da3aae.js.map +1 -0
  184. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/style.js +4 -0
  185. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js +712 -0
  186. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js.map +1 -0
  187. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js +533 -0
  188. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js.map +1 -0
  189. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js +6941 -0
  190. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js.map +1 -0
  191. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js +1021 -0
  192. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js.map +1 -0
  193. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js +59698 -0
  194. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js.map +1 -0
  195. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js +7440 -0
  196. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js.map +1 -0
  197. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js +2792 -0
  198. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js.map +1 -0
  199. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +4859 -0
  200. mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js.map +1 -0
  201. mito_ai-0.1.50.dist-info/METADATA +221 -0
  202. mito_ai-0.1.50.dist-info/RECORD +205 -0
  203. mito_ai-0.1.50.dist-info/WHEEL +4 -0
  204. mito_ai-0.1.50.dist-info/entry_points.txt +2 -0
  205. mito_ai-0.1.50.dist-info/licenses/LICENSE +3 -0
@@ -0,0 +1,401 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ import os
5
+ import time
6
+ import json
7
+ import uuid
8
+ from threading import Lock
9
+ from typing import Dict, List, Optional
10
+
11
+ from openai.types.chat import ChatCompletionMessageParam
12
+ from mito_ai.completions.models import CompletionRequest, ChatThreadMetadata, MessageType, ThreadID
13
+ from mito_ai.completions.prompt_builders.chat_name_prompt import create_chat_name_prompt
14
+ from mito_ai.completions.providers import OpenAIProvider
15
+ from mito_ai.utils.schema import MITO_FOLDER
16
+ from mito_ai.utils.message_history_utils import trim_old_messages
17
+
18
+ CHAT_HISTORY_VERSION = 2 # Increment this if the schema changes
19
+ NEW_CHAT_NAME = "(New Chat)"
20
+ NUMBER_OF_THREADS_CUT_OFF = 50
21
+
22
+ async def generate_short_chat_name(user_message: str, assistant_message: str, model: str, llm_provider: OpenAIProvider) -> str:
23
+ prompt = create_chat_name_prompt(user_message, assistant_message)
24
+
25
+ completion = await llm_provider.request_completions(
26
+ messages=[{"role": "user", "content": prompt}],
27
+ # We set the model so we can use the correct model provider, but request_completions will decide to
28
+ # use the fast model from that provider to make the request.
29
+ model=model,
30
+ message_type=MessageType.CHAT_NAME_GENERATION,
31
+ thread_id=None
32
+ )
33
+
34
+ # Do a little cleanup of the completion. Gemini seems to return the string
35
+ # wrapped in quotes and a newline character.
36
+ # TODO: Fix this problem upstream. I wonder if there is some extra encoding
37
+ # we are doing with the gemini responses.
38
+ if isinstance(completion, str):
39
+ # If completion has quotes around it, remove them
40
+ if completion.startswith('"') and completion.endswith('"'):
41
+ completion = completion[1:-1]
42
+
43
+ if completion.startswith("'") and completion.endswith("'"):
44
+ completion = completion[1:-1]
45
+
46
+ # If completion ends in a \n, remove it
47
+ completion = completion.strip()
48
+ completion = completion.replace("\n", "") # Remove newline character
49
+ completion = completion.replace("\\n", "") # Remove literal \ and n characters
50
+
51
+ if not completion or completion == "":
52
+ return "Untitled Chat"
53
+
54
+ return completion
55
+
56
+ class ChatThread:
57
+ """
58
+ Holds metadata + two lists of messages: LLM and display messages.
59
+ """
60
+ def __init__(
61
+ self,
62
+ thread_id: ThreadID,
63
+ creation_ts: float,
64
+ last_interaction_ts: float,
65
+ name: str,
66
+ ai_optimized_history: List[ChatCompletionMessageParam] = [],
67
+ display_history: List[ChatCompletionMessageParam] = [],
68
+ ):
69
+ self.thread_id = thread_id
70
+ self.creation_ts = creation_ts
71
+ self.last_interaction_ts = last_interaction_ts
72
+ self.name = name # short name for the thread
73
+ self.ai_optimized_history: List[ChatCompletionMessageParam] = ai_optimized_history or []
74
+ self.display_history: List[ChatCompletionMessageParam] = display_history or []
75
+ self.chat_history_version = CHAT_HISTORY_VERSION
76
+
77
+ class GlobalMessageHistory:
78
+ """
79
+ Manages a global message history with thread-safe chat conversations.
80
+
81
+ This class ensures thread-safe operations for reading, writing, and
82
+ modifying message histories using a Lock object. It supports loading
83
+ from and saving to disk, appending new messages, clearing histories,
84
+ and truncating histories. Each chat thread is stored in a separate JSON file
85
+ for persistence.
86
+
87
+ Thread safety is crucial to prevent data corruption and race conditions
88
+ when multiple threads access or modify the message histories concurrently.
89
+
90
+ We store two types of messages per thread: AI-optimized and display-optimized messages.
91
+ We store display_history to be able to restore them in the frontend when
92
+ the extension loads. We store ai_optimized_history to keep the conversation context
93
+ for continuing the conversation.
94
+
95
+ The JSON file structure for storing each thread is as follows:
96
+ {
97
+ "chat_history_version": 2,
98
+ "thread_id": "<uuid>",
99
+ "creation_ts": 1234567890.123,
100
+ "last_interaction_ts": 1234567890.123,
101
+ "name": "Short descriptive name",
102
+ "ai_optimized_history": [
103
+ {
104
+ "role": "user",
105
+ "content": "..."
106
+ },
107
+ {
108
+ "role": "assistant",
109
+ "content": "..."
110
+ }
111
+ ],
112
+ "display_history": [
113
+ {
114
+ "role": "user",
115
+ "content": "..."
116
+ },
117
+ {
118
+ "role": "assistant",
119
+ "content": "..."
120
+ }
121
+ ]
122
+ }
123
+
124
+ Each thread is stored in a separate JSON file named "<thread_id>.json".
125
+
126
+ Attributes:
127
+ _lock (Lock): Ensures thread-safe access.
128
+ _chats_dir (str): Directory where chat thread files are stored.
129
+ _chat_threads (Dict[ThreadID, ChatThread]): In-memory cache of all chat threads.
130
+
131
+ Methods:
132
+ create_new_thread() -> ThreadID:
133
+ Creates a new empty chat thread and returns its ID.
134
+ get_ai_optimized_history(thread_id: Optional[ThreadID] = None) -> List[ChatCompletionMessageParam]:
135
+ Returns the AI-optimized history for the specified thread or newest thread.
136
+ get_display_history(thread_id: Optional[ThreadID] = None) -> List[ChatCompletionMessageParam]:
137
+ Returns the display-optimized history for the specified thread or newest thread.
138
+ append_message(ai_optimized_message: ChatCompletionMessageParam, display_message: ChatCompletionMessageParam, llm_provider: OpenAIProvider, thread_id: Optional[ThreadID] = None) -> None:
139
+ Appends messages to the specified thread (or newest thread) and generates a name if needed.
140
+ truncate_histories(index: int, thread_id: Optional[ThreadID] = None) -> None:
141
+ Truncates messages at the given index for the specified thread.
142
+ delete_thread(thread_id: ThreadID) -> bool:
143
+ Deletes a chat thread by its ID from memory and disk, returns success status.
144
+ get_threads() -> List[ChatThreadMetadata]:
145
+ Returns a list of threads with metadata, sorted by last interaction (newest first).
146
+ """
147
+
148
+ def __init__(self) -> None:
149
+ self._lock = Lock()
150
+ self._chats_dir = os.path.join(MITO_FOLDER, "ai-chats")
151
+ os.makedirs(self._chats_dir, exist_ok=True)
152
+
153
+ # In-memory cache of all chat threads loaded from disk
154
+ self._chat_threads: Dict[ThreadID, ChatThread] = {}
155
+
156
+ # Load existing threads from disk on startup
157
+ self._load_all_threads_from_disk()
158
+
159
+ def create_new_thread(self) -> ThreadID:
160
+ """
161
+ Creates a new empty chat thread and saves it immediately.
162
+ """
163
+ with self._lock:
164
+ thread_id = ThreadID(str(uuid.uuid4()))
165
+ now = time.time()
166
+ new_thread = ChatThread(
167
+ thread_id=thread_id,
168
+ creation_ts=now,
169
+ last_interaction_ts=now,
170
+ name=NEW_CHAT_NAME, # we'll fill this in once we have at least user & assistant messages
171
+ )
172
+ self._chat_threads[thread_id] = new_thread
173
+ self._save_thread_to_disk(new_thread)
174
+ return thread_id
175
+
176
+ def _load_all_threads_from_disk(self) -> None:
177
+ """
178
+ Loads each .json file in `self._chats_dir` into self._chat_threads.
179
+ """
180
+ files = os.listdir(self._chats_dir)
181
+ for file_name in files:
182
+ if not file_name.endswith(".json"):
183
+ continue
184
+ path = os.path.join(self._chats_dir, file_name)
185
+ try:
186
+ with open(path, "r", encoding="utf-8") as f:
187
+ data = json.load(f)
188
+
189
+ # Check version
190
+ file_version = data.get("chat_history_version", 0)
191
+ if file_version == CHAT_HISTORY_VERSION:
192
+ thread = ChatThread(
193
+ thread_id=ThreadID(data["thread_id"]),
194
+ creation_ts=data["creation_ts"],
195
+ last_interaction_ts=data["last_interaction_ts"],
196
+ name=data["name"],
197
+ ai_optimized_history=data.get("ai_optimized_history", []),
198
+ display_history=data.get("display_history", []),
199
+ )
200
+ self._chat_threads[thread.thread_id] = thread
201
+ else:
202
+ # If versions don't match, throw a warning
203
+ print(
204
+ f"Warning: Incompatible chat history version ({file_version}). "
205
+ f"Expected version {CHAT_HISTORY_VERSION}."
206
+ )
207
+ f.close()
208
+ except Exception as e:
209
+ print(f"Error loading chat thread from {path}: {e}")
210
+
211
+ def _save_thread_to_disk(self, thread: ChatThread) -> None:
212
+ """
213
+ Saves the given ChatThread to a JSON file `<thread_id>.json` in `self._chats_dir`.
214
+ """
215
+ path = os.path.join(self._chats_dir, f"{thread.thread_id}.json")
216
+
217
+ # Using a temporary file and rename for safer "atomic" writes
218
+ tmp_file = path + ".tmp"
219
+ try:
220
+ with open(tmp_file, "w", encoding="utf-8") as f:
221
+ json.dump(thread.__dict__, f, indent=2)
222
+ os.replace(tmp_file, path)
223
+ except Exception as e:
224
+ print(f"Error saving chat thread {thread.thread_id}: {e}")
225
+
226
+ def _get_newest_thread_id(self) -> Optional[ThreadID]:
227
+ """
228
+ Returns the thread_id of the thread with the latest 'last_interaction_ts'.
229
+ If no threads exist, return None.
230
+ """
231
+ if not self._chat_threads:
232
+ return None
233
+ return max(self._chat_threads, key=lambda tid: self._chat_threads[tid].last_interaction_ts)
234
+
235
+ def _update_last_interaction(self, thread: ChatThread) -> None:
236
+ thread.last_interaction_ts = time.time()
237
+
238
+ def get_ai_optimized_history(self, thread_id: ThreadID) -> List[ChatCompletionMessageParam]:
239
+ """
240
+ Returns the AI-optimized message history for the specified thread or the newest thread if not specified.
241
+ """
242
+ with self._lock:
243
+ if thread_id not in self._chat_threads:
244
+ return []
245
+ return self._chat_threads[thread_id].ai_optimized_history
246
+
247
+ def get_display_history(self, thread_id: ThreadID) -> List[ChatCompletionMessageParam]:
248
+ """
249
+ Returns the display-optimized message history for the specified thread or the newest thread if not specified.
250
+ """
251
+ with self._lock:
252
+ if thread_id not in self._chat_threads:
253
+ return []
254
+
255
+ thread = self._chat_threads[thread_id]
256
+ display_history = thread.display_history
257
+
258
+ # When we get a thread, update it's last interaction time so that if the
259
+ # user refreshes their browser, this chat will re-appear as the last opened chat.
260
+ self._update_last_interaction(thread)
261
+ self._save_thread_to_disk(thread)
262
+ return display_history
263
+
264
+ async def append_message(
265
+ self,
266
+ ai_optimized_message: ChatCompletionMessageParam,
267
+ display_message: ChatCompletionMessageParam,
268
+ model: str,
269
+ llm_provider: OpenAIProvider,
270
+ thread_id: ThreadID
271
+ ) -> None:
272
+ """
273
+ Appends the messages to the specified thread or the newest thread if not specified.
274
+ If there are no threads yet, create one.
275
+ We also detect if we should set a short name for the thread.
276
+ """
277
+
278
+ # Add messages and check if naming is needed while holding the lock
279
+ name_gen_input = None
280
+ with self._lock:
281
+ thread = self._chat_threads[thread_id]
282
+ thread.ai_optimized_history.append(ai_optimized_message)
283
+ thread.display_history.append(display_message)
284
+ self._update_last_interaction(thread)
285
+
286
+ # Trim old messages in ai_optimized_history to reduce token count
287
+ thread.ai_optimized_history = trim_old_messages(thread.ai_optimized_history)
288
+
289
+ if thread.name == NEW_CHAT_NAME and len(thread.display_history) >= 2:
290
+ # Retrieve first user and assistant messages from display_history
291
+ user_message = None
292
+ assistant_message = None
293
+ for msg in thread.display_history:
294
+ if msg["role"] == "user" and user_message is None:
295
+ user_message = msg["content"]
296
+ elif msg["role"] == "assistant" and assistant_message is None:
297
+ assistant_message = msg["content"]
298
+ if user_message and assistant_message:
299
+ break
300
+ if user_message and assistant_message:
301
+ name_gen_input = (user_message, assistant_message)
302
+
303
+ # Save the updated thread to disk
304
+ self._save_thread_to_disk(thread)
305
+
306
+ # Outside the lock, await the name generation if needed
307
+ if name_gen_input:
308
+ new_name = await generate_short_chat_name(str(name_gen_input[0]), str(name_gen_input[1]), model, llm_provider)
309
+ with self._lock:
310
+ # Update the thread's name if still required
311
+ thread = self._chat_threads[thread_id]
312
+ if thread is not None and thread.name == NEW_CHAT_NAME:
313
+ thread.name = new_name
314
+ self._save_thread_to_disk(thread)
315
+
316
+ def truncate_histories(self, index: int, thread_id: ThreadID) -> None:
317
+ """
318
+ For the newest thread, truncate messages at the given index.
319
+ """
320
+ if index < 0:
321
+ return
322
+
323
+ with self._lock:
324
+ thread = self._chat_threads[thread_id]
325
+ thread.ai_optimized_history = thread.ai_optimized_history[:index]
326
+ thread.display_history = thread.display_history[:index]
327
+ self._update_last_interaction(thread)
328
+ self._save_thread_to_disk(thread)
329
+
330
+ def delete_thread(self, thread_id: ThreadID) -> bool:
331
+ """
332
+ Deletes a chat thread by its ID. Removes both the in-memory entry and the JSON file.
333
+ Includes safety checks to ensure we're only deleting valid thread files.
334
+ """
335
+
336
+ # Safety check: validate thread_id is a properly formatted UUID
337
+ if not thread_id or not isinstance(thread_id, str):
338
+ print(f"Invalid thread_id: {thread_id}")
339
+ return False
340
+
341
+ # UUIDs should only contain alphanumeric chars and hyphens
342
+ if not all(c.isalnum() or c == '-' for c in thread_id):
343
+ print(f"Thread ID contains invalid characters: {thread_id}")
344
+ return False
345
+
346
+ with self._lock:
347
+ # Remove from in-memory cache if present
348
+ if thread_id in self._chat_threads:
349
+ del self._chat_threads[thread_id]
350
+
351
+ # Construct the file path
352
+ path = os.path.join(self._chats_dir, f"{thread_id}.json")
353
+
354
+ # Security check: ensure path is within the expected directory
355
+ if not os.path.normpath(path).startswith(os.path.normpath(self._chats_dir)):
356
+ print(f"Path traversal attempt detected: {path}")
357
+ return False
358
+
359
+ # Ensure we're only deleting .json files
360
+ if not path.endswith('.json'):
361
+ print(f"Not a .json file: {path}")
362
+ return False
363
+
364
+ # Check if the file exists and is actually a file (not directory)
365
+ if os.path.exists(path):
366
+ if not os.path.isfile(path):
367
+ print(f"Path exists but is not a file: {path}")
368
+ return False
369
+
370
+ try:
371
+ os.remove(path)
372
+ return True
373
+ except Exception as e:
374
+ print(f"Error deleting thread {thread_id}: {e}")
375
+ return False
376
+
377
+ return False
378
+
379
+ def get_threads(self) -> List[ChatThreadMetadata]:
380
+ """
381
+ Returns a list of all chat threads with keys:
382
+ - thread_id
383
+ - name
384
+ - creation_ts
385
+ - last_interaction_ts
386
+ The list is sorted by last_interaction_ts (newest first).
387
+ """
388
+ with self._lock:
389
+ threads = []
390
+ for thread in self._chat_threads.values():
391
+ threads.append(ChatThreadMetadata(
392
+ thread_id=thread.thread_id,
393
+ name=thread.name,
394
+ creation_ts=thread.creation_ts,
395
+ last_interaction_ts=thread.last_interaction_ts,
396
+ ))
397
+ threads.sort(key=lambda x: x.last_interaction_ts, reverse=True)
398
+
399
+ # Since we expect vast majority of chats are never going to be deleted,
400
+ # we cut off the list of threads to a reasonable number.
401
+ return threads[:NUMBER_OF_THREADS_CUT_OFF]