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.
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/PKG-INFO +1 -1
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/pyproject.toml +1 -1
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/models.py +22 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/openai_responses_processor.py +13 -8
- appkit_assistant-0.15.4/src/appkit_assistant/backend/services/thread_service.py +134 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/thread.py +9 -122
- appkit_assistant-0.15.4/src/appkit_assistant/logic/response_accumulator.py +228 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/thread_state.py +135 -332
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/.gitignore +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/README.md +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/docs/assistant.png +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/mcp_auth_service.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/model_manager.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processor.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/lorem_ipsum_processor.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/openai_base.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/openai_chat_completion_processor.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processors/perplexity_processor.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/repositories.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/system_prompt_cache.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/__init__.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/composer.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/composer_key_handler.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/mcp_server_dialogs.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/mcp_server_table.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/message.py +2 -2
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/system_prompt_editor.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/threadlist.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/tools_modal.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/configuration.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/mcp_server_state.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/system_prompt_state.py +0 -0
- {appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/thread_list_state.py +0 -0
|
@@ -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
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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)
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/thread.py
RENAMED
|
@@ -155,128 +155,15 @@ class Assistant:
|
|
|
155
155
|
# ThreadState.set_suggestions(suggestions)
|
|
156
156
|
|
|
157
157
|
return rx.flex(
|
|
158
|
-
#
|
|
159
|
-
rx.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/thread_state.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
93
|
-
|
|
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 =
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
974
|
-
"""
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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(
|
|
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
|
|
787
|
+
"Processing OAuth success: server_id=%s, server_name=%s",
|
|
991
788
|
server_id,
|
|
992
789
|
server_name,
|
|
993
790
|
)
|
|
994
|
-
|
|
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
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/mcp_auth_service.py
RENAMED
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/model_manager.py
RENAMED
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/processor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/backend/repositories.py
RENAMED
|
File without changes
|
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/__init__.py
RENAMED
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/composer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/message.py
RENAMED
|
@@ -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
|
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/threadlist.py
RENAMED
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/components/tools_modal.py
RENAMED
|
File without changes
|
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/mcp_server_state.py
RENAMED
|
File without changes
|
|
File without changes
|
{appkit_assistant-0.15.2 → appkit_assistant-0.15.4}/src/appkit_assistant/state/thread_list_state.py
RENAMED
|
File without changes
|