appkit-assistant 0.15.2__tar.gz → 0.15.4__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 (33) hide show
  1. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/PKG-INFO +1 -1
  2. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/pyproject.toml +1 -1
  3. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/models.py +22 -0
  4. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/openai_responses_processor.py +13 -8
  5. appkit_assistant-0.15.4/src/appkit_assistant/backend/services/thread_service.py +134 -0
  6. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/thread.py +9 -122
  7. appkit_assistant-0.15.4/src/appkit_assistant/logic/response_accumulator.py +228 -0
  8. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/thread_state.py +135 -332
  9. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/.gitignore +0 -0
  10. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/README.md +0 -0
  11. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/docs/assistant.png +0 -0
  12. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/mcp_auth_service.py +0 -0
  13. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/model_manager.py +0 -0
  14. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processor.py +0 -0
  15. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/lorem_ipsum_processor.py +0 -0
  16. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/openai_base.py +0 -0
  17. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/openai_chat_completion_processor.py +0 -0
  18. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/perplexity_processor.py +0 -0
  19. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/repositories.py +0 -0
  20. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/system_prompt_cache.py +0 -0
  21. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/__init__.py +0 -0
  22. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/composer.py +0 -0
  23. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/composer_key_handler.py +0 -0
  24. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/mcp_server_dialogs.py +0 -0
  25. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/mcp_server_table.py +0 -0
  26. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/message.py +2 -2
  27. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/system_prompt_editor.py +0 -0
  28. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/threadlist.py +0 -0
  29. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/tools_modal.py +0 -0
  30. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/configuration.py +0 -0
  31. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/mcp_server_state.py +0 -0
  32. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/system_prompt_state.py +0 -0
  33. {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/thread_list_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appkit-assistant
3
- Version: 0.15.2
3
+ Version: 0.15.4
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
@@ -7,7 +7,7 @@ dependencies = [
7
7
  "reflex>=0.8.22",
8
8
  ]
9
9
  name = "appkit-assistant"
10
- version = "0.15.2"
10
+ version = "0.15.4"
11
11
  description = "Add your description here"
12
12
  readme = "README.md"
13
13
  authors = [{ name = "Jens Rehpöhler" }]
@@ -88,6 +88,28 @@ class Message(BaseModel):
88
88
  done: bool = False
89
89
 
90
90
 
91
+ class ThinkingType(StrEnum):
92
+ REASONING = "reasoning"
93
+ TOOL_CALL = "tool_call"
94
+
95
+
96
+ class ThinkingStatus(StrEnum):
97
+ IN_PROGRESS = "in_progress"
98
+ COMPLETED = "completed"
99
+ ERROR = "error"
100
+
101
+
102
+ class Thinking(BaseModel):
103
+ type: ThinkingType
104
+ id: str # reasoning_session_id or tool_id
105
+ text: str
106
+ status: ThinkingStatus = ThinkingStatus.IN_PROGRESS
107
+ tool_name: str | None = None
108
+ parameters: str | None = None
109
+ result: str | None = None
110
+ error: str | None = None
111
+
112
+
91
113
  class AIModel(BaseModel):
92
114
  id: str
93
115
  text: str
@@ -86,15 +86,20 @@ class OpenAIResponsesProcessor(BaseOpenAIProcessor):
86
86
  except Exception as e:
87
87
  error_msg = str(e)
88
88
  logger.error("Error during response processing: %s", error_msg)
89
- # Yield error chunk to show user-friendly error message
90
- yield Chunk(
91
- type=ChunkType.ERROR,
92
- text=f"Ein Fehler ist aufgetreten: {error_msg}",
93
- chunk_metadata={
94
- "source": "responses_api",
95
- "error_type": type(e).__name__,
96
- },
89
+ # Only yield error chunk if NOT an auth error
90
+ # and no auth servers are pending (they'll show auth card instead)
91
+ is_auth_related = (
92
+ self._is_auth_error(error_msg) or self._pending_auth_servers
97
93
  )
94
+ if not is_auth_related:
95
+ yield Chunk(
96
+ type=ChunkType.ERROR,
97
+ text=f"Ein Fehler ist aufgetreten: {error_msg}",
98
+ chunk_metadata={
99
+ "source": "responses_api",
100
+ "error_type": type(e).__name__,
101
+ },
102
+ )
98
103
 
99
104
  # After processing (or on error), yield any pending auth requirements
100
105
  logger.debug(
@@ -0,0 +1,134 @@
1
+ import logging
2
+ import uuid
3
+
4
+ from appkit_assistant.backend.model_manager import ModelManager
5
+ from appkit_assistant.backend.models import (
6
+ AssistantThread,
7
+ Message,
8
+ ThreadModel,
9
+ ThreadStatus,
10
+ )
11
+ from appkit_assistant.backend.repositories import thread_repo
12
+ from appkit_commons.database.session import get_asyncdb_session
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ThreadService:
18
+ """
19
+ Service for managing assistant threads.
20
+ Handles creation, loading, and persistence of threads.
21
+ """
22
+
23
+ def __init__(self):
24
+ self.model_manager = ModelManager()
25
+
26
+ def create_new_thread(
27
+ self, current_model: str, user_roles: list[str] | None = None
28
+ ) -> ThreadModel:
29
+ """Create a new ephemeral thread model (not persisted)."""
30
+ if user_roles is None:
31
+ user_roles = []
32
+
33
+ all_models = self.model_manager.get_all_models()
34
+
35
+ # Validate or fallback model
36
+ available_model_ids = [
37
+ m.id
38
+ for m in all_models
39
+ if not m.requires_role or m.requires_role in user_roles
40
+ ]
41
+
42
+ selected_model = current_model
43
+ if selected_model not in available_model_ids:
44
+ selected_model = self.model_manager.get_default_model()
45
+ if available_model_ids and selected_model not in available_model_ids:
46
+ selected_model = available_model_ids[0]
47
+
48
+ return ThreadModel(
49
+ thread_id=str(uuid.uuid4()),
50
+ title="Neuer Chat",
51
+ prompt="",
52
+ messages=[],
53
+ state=ThreadStatus.NEW,
54
+ ai_model=selected_model,
55
+ active=True,
56
+ )
57
+
58
+ async def load_thread(
59
+ self, thread_id: str, user_id: str | int
60
+ ) -> ThreadModel | None:
61
+ """Load a thread from the database."""
62
+ async with get_asyncdb_session() as session:
63
+ # Ensure user_id is correct type if needed
64
+ user_id_val = (
65
+ int(user_id)
66
+ if isinstance(user_id, str) and user_id.isdigit()
67
+ else user_id
68
+ )
69
+
70
+ thread_entity = await thread_repo.find_by_thread_id_and_user(
71
+ session, thread_id, user_id_val
72
+ )
73
+
74
+ if not thread_entity:
75
+ return None
76
+
77
+ return ThreadModel(
78
+ thread_id=thread_entity.thread_id,
79
+ title=thread_entity.title,
80
+ state=ThreadStatus(thread_entity.state),
81
+ ai_model=thread_entity.ai_model,
82
+ active=thread_entity.active,
83
+ messages=[Message(**m) for m in thread_entity.messages],
84
+ )
85
+
86
+ async def save_thread(self, thread: ThreadModel, user_id: str | int) -> None:
87
+ """Persist or update a thread in the database."""
88
+ if not user_id:
89
+ logger.warning("Cannot save thread: No user ID provided")
90
+ return
91
+
92
+ try:
93
+ messages_dict = [m.model_dump() for m in thread.messages]
94
+ user_id_val = (
95
+ int(user_id)
96
+ if isinstance(user_id, str) and user_id.isdigit()
97
+ else user_id
98
+ )
99
+
100
+ async with get_asyncdb_session() as session:
101
+ existing = await thread_repo.find_by_thread_id_and_user(
102
+ session, thread.thread_id, user_id_val
103
+ )
104
+
105
+ state_value = (
106
+ thread.state.value
107
+ if hasattr(thread.state, "value")
108
+ else thread.state
109
+ )
110
+
111
+ if existing:
112
+ existing.title = thread.title
113
+ existing.state = state_value
114
+ existing.ai_model = thread.ai_model
115
+ existing.active = thread.active
116
+ existing.messages = messages_dict
117
+ # updated_at handled by DB/SQLModel defaults,
118
+ # explicit save triggers it
119
+ await thread_repo.save(session, existing)
120
+ else:
121
+ new_thread = AssistantThread(
122
+ thread_id=thread.thread_id,
123
+ user_id=user_id_val,
124
+ title=thread.title,
125
+ state=state_value,
126
+ ai_model=thread.ai_model,
127
+ active=thread.active,
128
+ messages=messages_dict,
129
+ )
130
+ await thread_repo.save(session, new_thread)
131
+
132
+ logger.debug("Saved thread to DB: %s", thread.thread_id)
133
+ except Exception as e:
134
+ logger.exception("Error saving thread %s: %s", thread.thread_id, e)
@@ -155,128 +155,15 @@ class Assistant:
155
155
  # ThreadState.set_suggestions(suggestions)
156
156
 
157
157
  return rx.flex(
158
- # Hidden element with user_id for OAuth validation
159
- rx.el.input(
160
- id="mcp-oauth-user-id",
161
- type="hidden",
162
- value=ThreadState.current_user_id,
163
- ),
164
- # Hidden button for OAuth callback - triggered by storage event
165
- rx.el.button(
166
- id="mcp-oauth-success-trigger",
167
- style={"display": "none"},
168
- on_click=lambda: ThreadState.handle_mcp_oauth_success_from_js(),
169
- ),
170
- # OAuth listener for localStorage changes (cross-window)
171
- rx.script(
172
- """
173
- (function() {
174
- // Prevent double processing
175
- if (window._mcpOAuthListenerInstalled) {
176
- console.log('[OAuth] Listener already installed, skipping');
177
- return;
178
- }
179
- window._mcpOAuthListenerInstalled = true;
180
- var lastProcessedTimestamp = 0;
181
-
182
- function getCurrentUserId() {
183
- var el = document.getElementById('mcp-oauth-user-id');
184
- return el ? el.value : '';
185
- }
186
- function processOAuthResult(data) {
187
- // Simple timestamp-based debouncing
188
- var now = Date.now();
189
- if (data.timestamp && data.timestamp === lastProcessedTimestamp) {
190
- console.log('[OAuth] Already processed this timestamp, skip');
191
- return false;
192
- }
193
- lastProcessedTimestamp = data.timestamp || now;
194
-
195
- var currentUserId = getCurrentUserId();
196
- console.log('[OAuth] Processing, userId:', data.userId,
197
- 'current:', currentUserId, 'timestamp:', data.timestamp);
198
-
199
- // Security: only process if user_id matches (or not set)
200
- if (data.userId && currentUserId &&
201
- String(data.userId) !== String(currentUserId)) {
202
- console.log('[OAuth] Ignoring - user mismatch');
203
- return false;
204
- }
205
-
206
- window._mcpOAuthData = data;
207
- console.log('[OAuth] Stored data in window._mcpOAuthData');
208
-
209
- var btn = document.getElementById('mcp-oauth-success-trigger');
210
- if (btn) {
211
- console.log('[OAuth] Clicking trigger button');
212
- btn.click();
213
- console.log('[OAuth] Button clicked successfully');
214
- } else {
215
- console.error('[OAuth] Trigger button not found!');
216
- }
217
- return true;
218
- }
219
-
220
- function checkLocalStorage() {
221
- var stored = localStorage.getItem('mcp-oauth-result');
222
- if (stored) {
223
- console.log('[OAuth] Found in localStorage:', stored);
224
- try {
225
- var data = JSON.parse(stored);
226
- if (data.type === 'mcp-oauth-success') {
227
- console.log('[OAuth] Valid OAuth data, removing from localStorage');
228
- localStorage.removeItem('mcp-oauth-result');
229
- return processOAuthResult(data);
230
- }
231
- } catch(e) {
232
- console.error('[OAuth] Parse error:', e);
233
- }
234
- }
235
- return false;
236
- }
237
-
238
- console.log('[OAuth] Installing listeners');
239
-
240
- window.addEventListener('storage', function(event) {
241
- console.log('[OAuth] Storage event:', event.key);
242
- if (event.key === 'mcp-oauth-result') {
243
- setTimeout(checkLocalStorage, 100);
244
- }
245
- });
246
-
247
- window.addEventListener('focus', function() {
248
- console.log('[OAuth] Window focus event');
249
- setTimeout(checkLocalStorage, 100);
250
- });
251
-
252
- document.addEventListener('visibilitychange', function() {
253
- if (!document.hidden) {
254
- console.log('[OAuth] Document visible');
255
- setTimeout(checkLocalStorage, 100);
256
- }
257
- });
258
-
259
- window.addEventListener('message', function(event) {
260
- console.log('[OAuth] postMessage received:', event.data);
261
- if (event.data && event.data.type === 'mcp-oauth-success') {
262
- console.log('[OAuth] Processing postMessage data');
263
- processOAuthResult(event.data);
264
- }
265
- });
266
-
267
- // Aggressive polling - check every 500ms
268
- var intervalId = setInterval(function() {
269
- if (checkLocalStorage()) {
270
- console.log('[OAuth] Success via polling, clearing interval');
271
- clearInterval(intervalId);
272
- }
273
- }, 500);
274
-
275
- // Initial check
276
- console.log('[OAuth] Initial localStorage check');
277
- checkLocalStorage();
278
- })();
279
- """
158
+ # OAuth result handler - triggers when popup sets localStorage
159
+ # rx.LocalStorage(sync=True) auto-syncs, rx.cond re-renders on change
160
+ rx.cond(
161
+ ThreadState.oauth_result != "",
162
+ rx.box(
163
+ on_mount=ThreadState.process_oauth_result,
164
+ style={"display": "none"},
165
+ ),
166
+ rx.fragment(),
280
167
  ),
281
168
  rx.cond(
282
169
  ThreadState.messages,
@@ -0,0 +1,228 @@
1
+ import logging
2
+ import uuid
3
+ from typing import Any
4
+
5
+ from appkit_assistant.backend.models import (
6
+ Chunk,
7
+ ChunkType,
8
+ Message,
9
+ MessageType,
10
+ Thinking,
11
+ ThinkingStatus,
12
+ ThinkingType,
13
+ )
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ResponseAccumulator:
19
+ """
20
+ Accumulates chunks from streaming response into structured data
21
+ (Messages, Thinking items, etc.) for UI display.
22
+ """
23
+
24
+ def __init__(self):
25
+ self.current_reasoning_session: str = ""
26
+ self.current_tool_session: str = ""
27
+ self.thinking_items: list[Thinking] = []
28
+ self.image_chunks: list[Chunk] = []
29
+ self.messages: list[Message] = []
30
+ self.show_thinking: bool = False
31
+ self.current_activity: str = ""
32
+
33
+ # State for auth/errors
34
+ self.pending_auth_server_id: str = ""
35
+ self.pending_auth_server_name: str = ""
36
+ self.pending_auth_url: str = ""
37
+ self.auth_required: bool = False
38
+ self.error: str | None = None
39
+
40
+ @property
41
+ def auth_required_data(self) -> dict[str, str]:
42
+ """Return the auth data as a dictionary for compatibility."""
43
+ return {
44
+ "server_id": self.pending_auth_server_id,
45
+ "server_name": self.pending_auth_server_name,
46
+ "auth_url": self.pending_auth_url,
47
+ }
48
+
49
+ def attach_messages_ref(self, messages: list[Message]) -> None:
50
+ """Attach a reference to the mutable messages list from state."""
51
+ self.messages = messages
52
+
53
+ def process_chunk(self, chunk: Chunk) -> None:
54
+ """Process a single chunk and update internal state."""
55
+ if chunk.type == ChunkType.TEXT:
56
+ if self.messages and self.messages[-1].type == MessageType.ASSISTANT:
57
+ self.messages[-1].text += chunk.text
58
+
59
+ elif chunk.type in (ChunkType.THINKING, ChunkType.THINKING_RESULT):
60
+ self._handle_reasoning_chunk(chunk)
61
+
62
+ elif chunk.type in (
63
+ ChunkType.TOOL_CALL,
64
+ ChunkType.TOOL_RESULT,
65
+ ChunkType.ACTION,
66
+ ):
67
+ self._handle_tool_chunk(chunk)
68
+
69
+ elif chunk.type in (ChunkType.IMAGE, ChunkType.IMAGE_PARTIAL):
70
+ self.image_chunks.append(chunk)
71
+
72
+ elif chunk.type == ChunkType.COMPLETION:
73
+ self.show_thinking = False
74
+
75
+ elif chunk.type == ChunkType.AUTH_REQUIRED:
76
+ self._handle_auth_required_chunk(chunk)
77
+
78
+ elif chunk.type == ChunkType.ERROR:
79
+ # We append it to the message text if it's not a hard error,
80
+ # or creates a new message?
81
+ # Existing logic was appending a new message.
82
+ self.messages.append(Message(text=chunk.text, type=MessageType.ERROR))
83
+ self.error = chunk.text
84
+
85
+ else:
86
+ logger.warning("Unhandled chunk type: %s", chunk.type)
87
+
88
+ def _get_or_create_tool_session(self, chunk: Chunk) -> str:
89
+ tool_id = chunk.chunk_metadata.get("tool_id")
90
+ if tool_id:
91
+ self.current_tool_session = tool_id
92
+ return tool_id
93
+
94
+ if chunk.type == ChunkType.TOOL_CALL:
95
+ # Count existing tool calls
96
+ tool_count = sum(
97
+ 1 for i in self.thinking_items if i.type == ThinkingType.TOOL_CALL
98
+ )
99
+ self.current_tool_session = f"tool_{tool_count}"
100
+ return self.current_tool_session
101
+
102
+ if self.current_tool_session:
103
+ return self.current_tool_session
104
+
105
+ # Fallback
106
+ tool_count = sum(
107
+ 1 for i in self.thinking_items if i.type == ThinkingType.TOOL_CALL
108
+ )
109
+ self.current_tool_session = f"tool_{tool_count}"
110
+ return self.current_tool_session
111
+
112
+ def _handle_reasoning_chunk(self, chunk: Chunk) -> None:
113
+ if chunk.type == ChunkType.THINKING:
114
+ self.show_thinking = True
115
+ self.current_activity = "Denke nach..."
116
+
117
+ reasoning_session = self._get_or_create_reasoning_session(chunk)
118
+
119
+ status = ThinkingStatus.IN_PROGRESS
120
+ text = ""
121
+ if chunk.type == ChunkType.THINKING:
122
+ text = chunk.text
123
+ elif chunk.type == ChunkType.THINKING_RESULT:
124
+ status = ThinkingStatus.COMPLETED
125
+
126
+ item = self._get_or_create_thinking_item(
127
+ reasoning_session, ThinkingType.REASONING, text=text, status=status
128
+ )
129
+
130
+ if chunk.type == ChunkType.THINKING:
131
+ if item.text and item.text != text:
132
+ item.text += f"\n{chunk.text}"
133
+ elif chunk.type == ChunkType.THINKING_RESULT:
134
+ item.status = ThinkingStatus.COMPLETED
135
+ if chunk.text:
136
+ item.text += f" {chunk.text}"
137
+
138
+ def _get_or_create_reasoning_session(self, chunk: Chunk) -> str:
139
+ reasoning_session = chunk.chunk_metadata.get("reasoning_session")
140
+ if reasoning_session:
141
+ return reasoning_session
142
+
143
+ last_item = self.thinking_items[-1] if self.thinking_items else None
144
+
145
+ should_create_new = (
146
+ not self.current_reasoning_session
147
+ or (last_item and last_item.type == ThinkingType.TOOL_CALL)
148
+ or (
149
+ last_item
150
+ and last_item.type == ThinkingType.REASONING
151
+ and last_item.status == ThinkingStatus.COMPLETED
152
+ )
153
+ )
154
+
155
+ if should_create_new:
156
+ self.current_reasoning_session = f"reasoning_{uuid.uuid4().hex[:8]}"
157
+
158
+ return self.current_reasoning_session
159
+
160
+ def _handle_tool_chunk(self, chunk: Chunk) -> None:
161
+ tool_id = self._get_or_create_tool_session(chunk)
162
+
163
+ tool_name = chunk.chunk_metadata.get("tool_name", "Unknown")
164
+ if chunk.type == ChunkType.TOOL_CALL:
165
+ self.current_activity = f"Nutze Werkzeug: {tool_name}..."
166
+
167
+ status = ThinkingStatus.IN_PROGRESS
168
+ text = ""
169
+ parameters = None
170
+ result = None
171
+ error = None
172
+
173
+ if chunk.type == ChunkType.TOOL_CALL:
174
+ parameters = chunk.chunk_metadata.get("parameters", chunk.text)
175
+ text = chunk.chunk_metadata.get("description", "")
176
+ elif chunk.type == ChunkType.TOOL_RESULT:
177
+ is_error = (
178
+ "error" in chunk.text.lower()
179
+ or "failed" in chunk.text.lower()
180
+ or chunk.chunk_metadata.get("error")
181
+ )
182
+ status = ThinkingStatus.ERROR if is_error else ThinkingStatus.COMPLETED
183
+ result = chunk.text
184
+ if is_error:
185
+ error = chunk.text
186
+ else:
187
+ text = chunk.text
188
+
189
+ item = self._get_or_create_thinking_item(
190
+ tool_id,
191
+ ThinkingType.TOOL_CALL,
192
+ text=text,
193
+ status=status,
194
+ tool_name=tool_name,
195
+ parameters=parameters,
196
+ result=result,
197
+ error=error,
198
+ )
199
+
200
+ if chunk.type == ChunkType.TOOL_CALL:
201
+ item.parameters = parameters
202
+ item.text = text
203
+ if not item.tool_name or item.tool_name == "Unknown":
204
+ item.tool_name = tool_name
205
+ item.status = ThinkingStatus.IN_PROGRESS
206
+ elif chunk.type == ChunkType.TOOL_RESULT:
207
+ item.status = status
208
+ item.result = result
209
+ item.error = error
210
+ elif chunk.type == ChunkType.ACTION:
211
+ item.text += f"\n---\nAktion: {chunk.text}"
212
+
213
+ def _get_or_create_thinking_item(
214
+ self, item_id: str, thinking_type: ThinkingType, **kwargs: Any
215
+ ) -> Thinking:
216
+ for item in self.thinking_items:
217
+ if item.type == thinking_type and item.id == item_id:
218
+ return item
219
+
220
+ new_item = Thinking(type=thinking_type, id=item_id, **kwargs)
221
+ self.thinking_items.append(new_item)
222
+ return new_item
223
+
224
+ def _handle_auth_required_chunk(self, chunk: Chunk) -> None:
225
+ self.pending_auth_server_id = chunk.chunk_metadata.get("server_id", "")
226
+ self.pending_auth_server_name = chunk.chunk_metadata.get("server_name", "")
227
+ self.pending_auth_url = chunk.chunk_metadata.get("auth_url", "")
228
+ self.auth_required = True
@@ -14,26 +14,27 @@ import json
14
14
  import logging
15
15
  import uuid
16
16
  from collections.abc import AsyncGenerator
17
- from enum import StrEnum
18
17
  from typing import Any
19
18
 
20
19
  import reflex as rx
21
- from pydantic import BaseModel
22
20
 
23
21
  from appkit_assistant.backend.model_manager import ModelManager
24
22
  from appkit_assistant.backend.models import (
25
23
  AIModel,
26
- AssistantThread,
27
24
  Chunk,
28
25
  ChunkType,
29
26
  MCPServer,
30
27
  Message,
31
28
  MessageType,
32
29
  Suggestion,
30
+ Thinking,
31
+ ThinkingType,
33
32
  ThreadModel,
34
33
  ThreadStatus,
35
34
  )
36
- from appkit_assistant.backend.repositories import mcp_server_repo, thread_repo
35
+ from appkit_assistant.backend.repositories import mcp_server_repo
36
+ from appkit_assistant.backend.services.thread_service import ThreadService
37
+ from appkit_assistant.logic.response_accumulator import ResponseAccumulator
37
38
  from appkit_assistant.state.thread_list_state import ThreadListState
38
39
  from appkit_commons.database.session import get_asyncdb_session
39
40
  from appkit_user.authentication.states import UserSession
@@ -41,28 +42,6 @@ from appkit_user.authentication.states import UserSession
41
42
  logger = logging.getLogger(__name__)
42
43
 
43
44
 
44
- class ThinkingType(StrEnum):
45
- REASONING = "reasoning"
46
- TOOL_CALL = "tool_call"
47
-
48
-
49
- class ThinkingStatus(StrEnum):
50
- IN_PROGRESS = "in_progress"
51
- COMPLETED = "completed"
52
- ERROR = "error"
53
-
54
-
55
- class Thinking(BaseModel):
56
- type: ThinkingType
57
- id: str # reasoning_session_id or tool_id
58
- text: str
59
- status: ThinkingStatus = ThinkingStatus.IN_PROGRESS
60
- tool_name: str | None = None
61
- parameters: str | None = None
62
- result: str | None = None
63
- error: str | None = None
64
-
65
-
66
45
  class ThreadState(rx.State):
67
46
  """State for managing the current active thread.
68
47
 
@@ -89,8 +68,11 @@ class ThreadState(rx.State):
89
68
  show_thinking: bool = False
90
69
  thinking_expanded: bool = False
91
70
  current_activity: str = ""
92
- current_reasoning_session: str = "" # Track current reasoning session
93
- current_tool_session: str = "" # Track current tool session when tool_id missing
71
+
72
+ # Internal logic helper (not reactive)
73
+ @property
74
+ def _thread_service(self) -> ThreadService:
75
+ return ThreadService()
94
76
 
95
77
  # MCP Server tool support state
96
78
  selected_mcp_servers: list[MCPServer] = []
@@ -105,6 +87,8 @@ class ThreadState(rx.State):
105
87
  pending_auth_url: str = ""
106
88
  show_auth_card: bool = False
107
89
  pending_oauth_message: str = "" # Message that triggered OAuth, resent on success
90
+ # Cross-tab synced localStorage - triggers re-render when popup sets value
91
+ oauth_result: str = rx.LocalStorage(name="mcp-oauth-result", sync=True)
108
92
 
109
93
  # Thread list integration
110
94
  with_thread_list: bool = False
@@ -220,14 +204,9 @@ class ThreadState(rx.State):
220
204
  logger.warning("No models available for user")
221
205
  self.selected_model = ""
222
206
 
223
- self._thread = ThreadModel(
224
- thread_id=str(uuid.uuid4()),
225
- title="Neuer Chat",
226
- prompt="",
227
- messages=[],
228
- state=ThreadStatus.NEW,
229
- ai_model=self.selected_model,
230
- active=True,
207
+ self._thread = self._thread_service.create_new_thread(
208
+ current_model=self.selected_model,
209
+ user_roles=user_roles,
231
210
  )
232
211
  self.messages = []
233
212
  self.thinking_items = []
@@ -254,14 +233,13 @@ class ThreadState(rx.State):
254
233
  logger.debug("Thread already empty, skipping new_thread")
255
234
  return
256
235
 
257
- self._thread = ThreadModel(
258
- thread_id=str(uuid.uuid4()),
259
- title="Neuer Chat",
260
- prompt="",
261
- messages=[],
262
- state=ThreadStatus.NEW,
263
- ai_model=self.selected_model or ModelManager().get_default_model(),
264
- active=True,
236
+ # Need user roles for create_new_thread
237
+ user_session: UserSession = await self.get_state(UserSession)
238
+ user = await user_session.authenticated_user
239
+ user_roles = user.roles if user else []
240
+
241
+ self._thread = self._thread_service.create_new_thread(
242
+ current_model=self.selected_model, user_roles=user_roles
265
243
  )
266
244
  self.messages = []
267
245
  self.thinking_items = []
@@ -309,27 +287,10 @@ class ThreadState(rx.State):
309
287
  return
310
288
 
311
289
  try:
312
- async with get_asyncdb_session() as session:
313
- thread_entity = await thread_repo.find_by_thread_id_and_user(
314
- session, thread_id, user_id
315
- )
316
-
317
- if not thread_entity:
318
- logger.warning("Thread %s not found in database", thread_id)
319
-
320
- # Convert to ThreadModel if found
321
- full_thread = None
322
- if thread_entity:
323
- full_thread = ThreadModel(
324
- thread_id=thread_entity.thread_id,
325
- title=thread_entity.title,
326
- state=ThreadStatus(thread_entity.state),
327
- ai_model=thread_entity.ai_model,
328
- active=thread_entity.active,
329
- messages=[Message(**m) for m in thread_entity.messages],
330
- )
290
+ full_thread = await self._thread_service.load_thread(thread_id, user_id)
331
291
 
332
- if not full_thread and not thread_entity: # it was not found
292
+ if not full_thread: # it was not found
293
+ logger.warning("Thread %s not found in database", thread_id)
333
294
  async with self:
334
295
  threadlist_state: ThreadListState = await self.get_state(
335
296
  ThreadListState
@@ -338,9 +299,8 @@ class ThreadState(rx.State):
338
299
  return
339
300
 
340
301
  # Mark all messages as done (loaded from DB)
341
- if full_thread:
342
- for msg in full_thread.messages:
343
- msg.done = True
302
+ for msg in full_thread.messages:
303
+ msg.done = True
344
304
 
345
305
  async with self:
346
306
  # Update self with loaded thread
@@ -511,6 +471,10 @@ class ThreadState(rx.State):
511
471
  user_session: UserSession = await self.get_state(UserSession)
512
472
  user_id = user_session.user.user_id if user_session.user else None
513
473
 
474
+ # Initialize ResponseAccumulator logic
475
+ accumulator = ResponseAccumulator()
476
+ accumulator.attach_messages_ref(self.messages)
477
+
514
478
  first_response_received = False
515
479
  try:
516
480
  async for chunk in processor.process(
@@ -521,12 +485,13 @@ class ThreadState(rx.State):
521
485
  ):
522
486
  first_response_received = await self._handle_stream_chunk(
523
487
  chunk=chunk,
488
+ accumulator=accumulator,
524
489
  current_prompt=current_prompt,
525
490
  is_new_thread=is_new_thread,
526
491
  first_response_received=first_response_received,
527
492
  )
528
493
 
529
- await self._finalize_successful_response()
494
+ await self._finalize_successful_response(accumulator)
530
495
 
531
496
  except Exception as ex:
532
497
  await self._handle_process_error(
@@ -549,7 +514,9 @@ class ThreadState(rx.State):
549
514
  return None
550
515
 
551
516
  self.processing = True
552
- self._clear_chunks()
517
+ # Clearing chunks now only resets direct UI state if needed,
518
+ # but accumulator handles logic
519
+ self.image_chunks = []
553
520
  self.thinking_items = []
554
521
 
555
522
  self.prompt = ""
@@ -585,13 +552,29 @@ class ThreadState(rx.State):
585
552
  self,
586
553
  *,
587
554
  chunk: Chunk,
555
+ accumulator: ResponseAccumulator,
588
556
  current_prompt: str,
589
557
  is_new_thread: bool,
590
558
  first_response_received: bool,
591
559
  ) -> bool:
592
560
  """Handle one streamed chunk. Returns updated first_response_received."""
593
561
  async with self:
594
- self._handle_chunk(chunk)
562
+ accumulator.process_chunk(chunk)
563
+
564
+ # Sync UI state from accumulator
565
+ # Create copy to trigger update
566
+ self.thinking_items = list(accumulator.thinking_items)
567
+ self.current_activity = accumulator.current_activity
568
+ if accumulator.show_thinking:
569
+ self.show_thinking = True
570
+ if accumulator.image_chunks:
571
+ # Append only new ones or sync list? image_chunks is list[Chunk]
572
+ # Accumulator has all of them.
573
+ self.image_chunks = list(accumulator.image_chunks)
574
+
575
+ # Handle Auth Required which might be set on accumulator state
576
+ if accumulator.auth_required:
577
+ self._handle_auth_required_from_accumulator(accumulator)
595
578
 
596
579
  should_create_thread = (
597
580
  not first_response_received
@@ -608,17 +591,26 @@ class ThreadState(rx.State):
608
591
  await self._notify_thread_created()
609
592
  return True
610
593
 
611
- async def _finalize_successful_response(self) -> None:
594
+ async def _finalize_successful_response(
595
+ self, accumulator: ResponseAccumulator
596
+ ) -> None:
612
597
  """Finalize state after a successful full response."""
613
598
  async with self:
614
599
  self.show_thinking = False
600
+
601
+ # Final sync
602
+ self.thinking_items = list(accumulator.thinking_items)
603
+
615
604
  # Convert Reflex proxy list to standard list to avoid Pydantic
616
605
  # serializer warnings
617
606
  self._thread.messages = list(self.messages) # noqa: E501
618
607
  self._thread.ai_model = self.selected_model
619
608
 
620
609
  if self.with_thread_list:
621
- await self._save_thread_to_db()
610
+ user_session: UserSession = await self.get_state(UserSession)
611
+ user_id = user_session.user.user_id if user_session.user else None
612
+ if user_id:
613
+ await self._thread_service.save_thread(self._thread, user_id)
622
614
 
623
615
  async def _handle_process_error(
624
616
  self,
@@ -645,7 +637,10 @@ class ThreadState(rx.State):
645
637
  # warnings
646
638
  self._thread.messages = list(self.messages) # noqa: E501
647
639
  if self.with_thread_list:
648
- await self._save_thread_to_db()
640
+ user_session: UserSession = await self.get_state(UserSession)
641
+ user_id = user_session.user.user_id if user_session.user else None
642
+ if user_id:
643
+ await self._thread_service.save_thread(self._thread, user_id)
649
644
 
650
645
  async def _finalize_processing(self) -> None:
651
646
  """Mark processing done and close out the last message."""
@@ -653,6 +648,33 @@ class ThreadState(rx.State):
653
648
  if self.messages:
654
649
  self.messages[-1].done = True
655
650
  self.processing = False
651
+ self.current_activity = ""
652
+
653
+ def _handle_auth_required_from_accumulator(
654
+ self, accumulator: ResponseAccumulator
655
+ ) -> None:
656
+ """Handle auth required state from accumulator."""
657
+ self.pending_auth_server_id = accumulator.auth_required_data.get(
658
+ "server_id", ""
659
+ )
660
+ self.pending_auth_server_name = accumulator.auth_required_data.get(
661
+ "server_name", ""
662
+ )
663
+ self.pending_auth_url = accumulator.auth_required_data.get("auth_url", "")
664
+ self.show_auth_card = True
665
+
666
+ # Reset flag in accumulator so we don't trigger this again for same event
667
+ accumulator.auth_required = False
668
+
669
+ # Store the last user message to resend after successful OAuth
670
+ for msg in reversed(self.messages):
671
+ if msg.type == MessageType.HUMAN:
672
+ self.pending_oauth_message = msg.text
673
+ break
674
+ logger.debug(
675
+ "Auth required for server %s, showing auth card",
676
+ self.pending_auth_server_name,
677
+ )
656
678
 
657
679
  # -------------------------------------------------------------------------
658
680
  # Thread persistence (internal)
@@ -669,255 +691,13 @@ class ThreadState(rx.State):
669
691
  threadlist_state: ThreadListState = await self.get_state(ThreadListState)
670
692
  await threadlist_state.add_thread(self._thread)
671
693
 
672
- async def _save_thread_to_db(self) -> None:
673
- """Persist current thread to database.
674
-
675
- Called incrementally after each successful response.
676
- """
677
- user_session: UserSession = await self.get_state(UserSession)
678
- user_id = user_session.user.user_id if user_session.user else None
679
-
680
- if user_id:
681
- try:
682
- # Prepare entity data
683
- messages_dict = [m.dict() for m in self._thread.messages]
684
-
685
- async with get_asyncdb_session() as session:
686
- # Check if exists
687
- existing = await thread_repo.find_by_thread_id_and_user(
688
- session, self._thread.thread_id, user_id
689
- )
690
-
691
- if existing:
692
- existing.title = self._thread.title
693
- existing.state = (
694
- self._thread.state.value
695
- if hasattr(self._thread.state, "value")
696
- else self._thread.state
697
- )
698
- existing.ai_model = self._thread.ai_model
699
- existing.active = self._thread.active
700
- existing.messages = messages_dict
701
- await thread_repo.save(session, existing)
702
- else:
703
- new_thread = AssistantThread(
704
- thread_id=self._thread.thread_id,
705
- user_id=user_id,
706
- title=self._thread.title,
707
- state=self._thread.state.value
708
- if hasattr(self._thread.state, "value")
709
- else self._thread.state,
710
- ai_model=self._thread.ai_model,
711
- active=self._thread.active,
712
- messages=messages_dict,
713
- )
714
- await thread_repo.save(session, new_thread)
715
-
716
- logger.debug("Saved thread to DB: %s", self._thread.thread_id)
717
- except Exception as e:
718
- logger.error("Error saving thread %s: %s", self._thread.thread_id, e)
694
+ # _save_thread_to_db removed, using self._thread_service.save_thread
719
695
 
720
696
  # -------------------------------------------------------------------------
721
697
  # Chunk handling (internal)
698
+ # Logic moved to ResponseAccumulator
722
699
  # -------------------------------------------------------------------------
723
700
 
724
- def _clear_chunks(self) -> None:
725
- """Clear all chunk categorization lists except thinking_items for display."""
726
- self.image_chunks = []
727
- self.current_reasoning_session = "" # Reset reasoning session for new message
728
- self.current_tool_session = "" # Reset tool session for new message
729
-
730
- def _get_or_create_tool_session(self, chunk: Chunk) -> str:
731
- """Get tool session ID from metadata or derive one.
732
-
733
- If the model doesn't include tool_id in chunk metadata, we track the latest
734
- tool session so TOOL_RESULT can be associated with the preceding TOOL_CALL.
735
- """
736
- tool_id = chunk.chunk_metadata.get("tool_id")
737
- if tool_id:
738
- self.current_tool_session = tool_id
739
- return tool_id
740
-
741
- if chunk.type == ChunkType.TOOL_CALL:
742
- tool_count = sum(
743
- 1 for i in self.thinking_items if i.type == ThinkingType.TOOL_CALL
744
- )
745
- self.current_tool_session = f"tool_{tool_count}"
746
- return self.current_tool_session
747
-
748
- if self.current_tool_session:
749
- return self.current_tool_session
750
-
751
- tool_count = sum(
752
- 1 for i in self.thinking_items if i.type == ThinkingType.TOOL_CALL
753
- )
754
- self.current_tool_session = f"tool_{tool_count}"
755
- return self.current_tool_session
756
-
757
- def _handle_chunk(self, chunk: Chunk) -> None:
758
- """Handle incoming chunk based on its type."""
759
- if chunk.type == ChunkType.TEXT:
760
- self.messages[-1].text += chunk.text
761
- elif chunk.type in (ChunkType.THINKING, ChunkType.THINKING_RESULT):
762
- self._handle_reasoning_chunk(chunk)
763
- elif chunk.type in (
764
- ChunkType.TOOL_CALL,
765
- ChunkType.TOOL_RESULT,
766
- ChunkType.ACTION,
767
- ):
768
- self._handle_tool_chunk(chunk)
769
- elif chunk.type in (ChunkType.IMAGE, ChunkType.IMAGE_PARTIAL):
770
- self.image_chunks.append(chunk)
771
- elif chunk.type == ChunkType.COMPLETION:
772
- self.show_thinking = False
773
- logger.debug("Response generation completed")
774
- elif chunk.type == ChunkType.AUTH_REQUIRED:
775
- self._handle_auth_required_chunk(chunk)
776
- elif chunk.type == ChunkType.ERROR:
777
- self.messages.append(Message(text=chunk.text, type=MessageType.ERROR))
778
- logger.error("Chunk error: %s", chunk.text)
779
- else:
780
- logger.warning("Unhandled chunk type: %s - %s", chunk.type, chunk.text)
781
-
782
- def _get_or_create_thinking_item(
783
- self, item_id: str, thinking_type: ThinkingType, **kwargs
784
- ) -> Thinking:
785
- """Get existing thinking item or create new one."""
786
- for item in self.thinking_items:
787
- if item.type == thinking_type and item.id == item_id:
788
- return item
789
-
790
- new_item = Thinking(type=thinking_type, id=item_id, **kwargs)
791
- self.thinking_items = [*self.thinking_items, new_item]
792
- return new_item
793
-
794
- def _handle_reasoning_chunk(self, chunk: Chunk) -> None:
795
- """Handle reasoning chunks by consolidating them into thinking items."""
796
- if chunk.type == ChunkType.THINKING:
797
- self.show_thinking = True
798
-
799
- reasoning_session = self._get_or_create_reasoning_session(chunk)
800
-
801
- # Determine status and text
802
- status = ThinkingStatus.IN_PROGRESS
803
- text = ""
804
- if chunk.type == ChunkType.THINKING:
805
- text = chunk.text
806
- elif chunk.type == ChunkType.THINKING_RESULT:
807
- status = ThinkingStatus.COMPLETED
808
-
809
- item = self._get_or_create_thinking_item(
810
- reasoning_session, ThinkingType.REASONING, text=text, status=status
811
- )
812
-
813
- # Update existing item
814
- if chunk.type == ChunkType.THINKING:
815
- if item.text and item.text != text: # Append if not new
816
- item.text += f"\n{chunk.text}"
817
- elif chunk.type == ChunkType.THINKING_RESULT:
818
- item.status = ThinkingStatus.COMPLETED
819
- if chunk.text:
820
- item.text += f" {chunk.text}"
821
-
822
- self.thinking_items = self.thinking_items.copy()
823
-
824
- def _get_or_create_reasoning_session(self, chunk: Chunk) -> str:
825
- """Get reasoning session ID from metadata or create new one."""
826
- reasoning_session = chunk.chunk_metadata.get("reasoning_session")
827
- if reasoning_session:
828
- return reasoning_session
829
-
830
- # If no session ID in metadata, create separate sessions based on context
831
- last_item = self.thinking_items[-1] if self.thinking_items else None
832
-
833
- # Create new session if needed
834
- should_create_new_session = (
835
- not self.current_reasoning_session
836
- or (last_item and last_item.type == ThinkingType.TOOL_CALL)
837
- or (
838
- last_item
839
- and last_item.type == ThinkingType.REASONING
840
- and last_item.status == ThinkingStatus.COMPLETED
841
- )
842
- )
843
-
844
- if should_create_new_session:
845
- self.current_reasoning_session = f"reasoning_{uuid.uuid4().hex[:8]}"
846
-
847
- return self.current_reasoning_session
848
-
849
- def _handle_tool_chunk(self, chunk: Chunk) -> None:
850
- """Handle tool chunks by consolidating them into thinking items."""
851
- tool_id = self._get_or_create_tool_session(chunk)
852
-
853
- # Determine initial properties
854
- tool_name = chunk.chunk_metadata.get("tool_name", "Unknown")
855
- status = ThinkingStatus.IN_PROGRESS
856
- text = ""
857
- parameters = None
858
- result = None
859
- error = None
860
-
861
- if chunk.type == ChunkType.TOOL_CALL:
862
- parameters = chunk.chunk_metadata.get("parameters", chunk.text)
863
- text = chunk.chunk_metadata.get("description", "")
864
- elif chunk.type == ChunkType.TOOL_RESULT:
865
- is_error = (
866
- "error" in chunk.text.lower()
867
- or "failed" in chunk.text.lower()
868
- or chunk.chunk_metadata.get("error")
869
- )
870
- status = ThinkingStatus.ERROR if is_error else ThinkingStatus.COMPLETED
871
- result = chunk.text
872
- if is_error:
873
- error = chunk.text
874
- else:
875
- text = chunk.text
876
-
877
- item = self._get_or_create_thinking_item(
878
- tool_id,
879
- ThinkingType.TOOL_CALL,
880
- text=text,
881
- status=status,
882
- tool_name=tool_name,
883
- parameters=parameters,
884
- result=result,
885
- error=error,
886
- )
887
-
888
- # Update existing item
889
- if chunk.type == ChunkType.TOOL_CALL:
890
- item.parameters = parameters
891
- item.text = text
892
- if not item.tool_name or item.tool_name == "Unknown":
893
- item.tool_name = tool_name
894
- item.status = ThinkingStatus.IN_PROGRESS
895
- elif chunk.type == ChunkType.TOOL_RESULT:
896
- item.status = status
897
- item.result = result
898
- item.error = error
899
- elif chunk.type == ChunkType.ACTION:
900
- item.text += f"\n---\nAktion: {chunk.text}"
901
-
902
- self.thinking_items = self.thinking_items.copy()
903
-
904
- def _handle_auth_required_chunk(self, chunk: Chunk) -> None:
905
- """Handle AUTH_REQUIRED chunks by showing the auth card."""
906
- self.pending_auth_server_id = chunk.chunk_metadata.get("server_id", "")
907
- self.pending_auth_server_name = chunk.chunk_metadata.get("server_name", "")
908
- self.pending_auth_url = chunk.chunk_metadata.get("auth_url", "")
909
- self.show_auth_card = True
910
- # Store the last user message to resend after successful OAuth
911
- for msg in reversed(self.messages):
912
- if msg.type == MessageType.HUMAN:
913
- self.pending_oauth_message = msg.text
914
- break
915
- logger.debug(
916
- "Auth required for server %s, showing auth card, pending message: %s",
917
- self.pending_auth_server_name,
918
- self.pending_oauth_message[:50] if self.pending_oauth_message else "None",
919
- )
920
-
921
701
  @rx.event
922
702
  def start_mcp_oauth(self) -> rx.event.EventSpec:
923
703
  """Start the OAuth flow by opening the auth URL in a popup window."""
@@ -970,32 +750,55 @@ class ThreadState(rx.State):
970
750
  )
971
751
 
972
752
  @rx.event
973
- def handle_mcp_oauth_success_from_js(self) -> rx.event.EventSpec:
974
- """Handle OAuth success triggered from JS - retrieves data from window."""
975
- return rx.call_script(
976
- "window._mcpOAuthData ? JSON.stringify(window._mcpOAuthData) : '{}'",
977
- callback=ThreadState.process_oauth_success_data,
978
- )
753
+ async def process_oauth_result(self) -> AsyncGenerator[Any, Any]:
754
+ """Process OAuth result from synced LocalStorage.
755
+
756
+ Called via on_mount when oauth_result becomes non-empty.
757
+ The rx.LocalStorage(sync=True) automatically syncs from popup.
758
+ """
759
+ if not self.oauth_result:
760
+ return
979
761
 
980
- @rx.event
981
- async def process_oauth_success_data(
982
- self, data_str: str
983
- ) -> AsyncGenerator[Any, Any]:
984
- """Process OAuth success data retrieved from window."""
985
762
  try:
986
- data = json.loads(data_str) if data_str else {}
763
+ data = json.loads(self.oauth_result)
764
+ if data.get("type") != "mcp-oauth-success":
765
+ return
766
+
987
767
  server_id = data.get("serverId", "")
988
768
  server_name = data.get("serverName", "Unknown")
769
+ user_id = data.get("userId", "")
770
+
771
+ # Security: verify user_id matches
772
+ if (
773
+ user_id
774
+ and self._current_user_id
775
+ and str(user_id) != str(self._current_user_id)
776
+ ):
777
+ logger.warning(
778
+ "OAuth user mismatch: got %s, expected %s",
779
+ user_id,
780
+ self._current_user_id,
781
+ )
782
+ # Clear invalid data
783
+ self.oauth_result = ""
784
+ return
785
+
989
786
  logger.info(
990
- "Processing OAuth success from JS: server_id=%s, server_name=%s",
787
+ "Processing OAuth success: server_id=%s, server_name=%s",
991
788
  server_id,
992
789
  server_name,
993
790
  )
994
- # Yield events from handle_mcp_oauth_success
791
+
792
+ # Clear localStorage before processing to prevent re-triggers
793
+ self.oauth_result = ""
794
+
795
+ # Process the OAuth success
995
796
  async for event in self.handle_mcp_oauth_success(server_id, server_name):
996
797
  yield event
798
+
997
799
  except json.JSONDecodeError:
998
- logger.warning("Failed to parse OAuth data from JS: %s", data_str)
800
+ logger.warning("Failed to parse OAuth result: %s", self.oauth_result)
801
+ self.oauth_result = ""
999
802
 
1000
803
  @rx.event
1001
804
  def dismiss_auth_card(self) -> None:
@@ -4,11 +4,11 @@ import appkit_mantine as mn
4
4
  from appkit_assistant.backend.models import (
5
5
  Message,
6
6
  MessageType,
7
- )
8
- from appkit_assistant.state.thread_state import (
9
7
  Thinking,
10
8
  ThinkingStatus,
11
9
  ThinkingType,
10
+ )
11
+ from appkit_assistant.state.thread_state import (
12
12
  ThreadState,
13
13
  )
14
14
  from appkit_ui.components.collabsible import collabsible