appkit-assistant 0.17.3__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. appkit_assistant/backend/{models.py → database/models.py} +32 -132
  2. appkit_assistant/backend/{repositories.py → database/repositories.py} +93 -1
  3. appkit_assistant/backend/model_manager.py +5 -5
  4. appkit_assistant/backend/models/__init__.py +28 -0
  5. appkit_assistant/backend/models/anthropic.py +31 -0
  6. appkit_assistant/backend/models/google.py +27 -0
  7. appkit_assistant/backend/models/openai.py +50 -0
  8. appkit_assistant/backend/models/perplexity.py +56 -0
  9. appkit_assistant/backend/processors/__init__.py +29 -0
  10. appkit_assistant/backend/processors/claude_responses_processor.py +205 -387
  11. appkit_assistant/backend/processors/gemini_responses_processor.py +231 -299
  12. appkit_assistant/backend/processors/lorem_ipsum_processor.py +6 -4
  13. appkit_assistant/backend/processors/mcp_mixin.py +297 -0
  14. appkit_assistant/backend/processors/openai_base.py +11 -125
  15. appkit_assistant/backend/processors/openai_chat_completion_processor.py +5 -3
  16. appkit_assistant/backend/processors/openai_responses_processor.py +480 -402
  17. appkit_assistant/backend/processors/perplexity_processor.py +156 -79
  18. appkit_assistant/backend/{processor.py → processors/processor_base.py} +7 -2
  19. appkit_assistant/backend/processors/streaming_base.py +188 -0
  20. appkit_assistant/backend/schemas.py +138 -0
  21. appkit_assistant/backend/services/auth_error_detector.py +99 -0
  22. appkit_assistant/backend/services/chunk_factory.py +273 -0
  23. appkit_assistant/backend/services/citation_handler.py +292 -0
  24. appkit_assistant/backend/services/file_cleanup_service.py +316 -0
  25. appkit_assistant/backend/services/file_upload_service.py +903 -0
  26. appkit_assistant/backend/services/file_validation.py +138 -0
  27. appkit_assistant/backend/{mcp_auth_service.py → services/mcp_auth_service.py} +4 -2
  28. appkit_assistant/backend/services/mcp_token_service.py +61 -0
  29. appkit_assistant/backend/services/message_converter.py +289 -0
  30. appkit_assistant/backend/services/openai_client_service.py +120 -0
  31. appkit_assistant/backend/{response_accumulator.py → services/response_accumulator.py} +163 -1
  32. appkit_assistant/backend/services/system_prompt_builder.py +89 -0
  33. appkit_assistant/backend/services/thread_service.py +5 -3
  34. appkit_assistant/backend/system_prompt_cache.py +3 -3
  35. appkit_assistant/components/__init__.py +8 -4
  36. appkit_assistant/components/composer.py +59 -24
  37. appkit_assistant/components/file_manager.py +623 -0
  38. appkit_assistant/components/mcp_server_dialogs.py +12 -20
  39. appkit_assistant/components/mcp_server_table.py +12 -2
  40. appkit_assistant/components/message.py +119 -2
  41. appkit_assistant/components/thread.py +1 -1
  42. appkit_assistant/components/threadlist.py +4 -2
  43. appkit_assistant/components/tools_modal.py +37 -20
  44. appkit_assistant/configuration.py +12 -0
  45. appkit_assistant/state/file_manager_state.py +697 -0
  46. appkit_assistant/state/mcp_oauth_state.py +3 -3
  47. appkit_assistant/state/mcp_server_state.py +47 -2
  48. appkit_assistant/state/system_prompt_state.py +1 -1
  49. appkit_assistant/state/thread_list_state.py +99 -5
  50. appkit_assistant/state/thread_state.py +88 -9
  51. {appkit_assistant-0.17.3.dist-info → appkit_assistant-1.0.0.dist-info}/METADATA +8 -6
  52. appkit_assistant-1.0.0.dist-info/RECORD +58 -0
  53. appkit_assistant/backend/processors/claude_base.py +0 -178
  54. appkit_assistant/backend/processors/gemini_base.py +0 -84
  55. appkit_assistant-0.17.3.dist-info/RECORD +0 -39
  56. /appkit_assistant/backend/{file_manager.py → services/file_manager.py} +0 -0
  57. {appkit_assistant-0.17.3.dist-info → appkit_assistant-1.0.0.dist-info}/WHEEL +0 -0
@@ -1,8 +1,10 @@
1
+ import json
1
2
  import logging
3
+ import re
2
4
  import uuid
3
5
  from typing import Any
4
6
 
5
- from appkit_assistant.backend.models import (
7
+ from appkit_assistant.backend.schemas import (
6
8
  Chunk,
7
9
  ChunkType,
8
10
  Message,
@@ -14,6 +16,49 @@ from appkit_assistant.backend.models import (
14
16
 
15
17
  logger = logging.getLogger(__name__)
16
18
 
19
+ # Minimum number of consecutive links required to format as a list
20
+ MIN_LINKS_FOR_LIST_FORMAT = 2
21
+
22
+
23
+ def _format_consecutive_links_as_list(text: str) -> str:
24
+ """Format multiple consecutive markdown links as a bullet list.
25
+
26
+ Detects patterns where markdown links are concatenated directly without
27
+ spacing (e.g., `[text1](url1)[text2](url2)`) and converts them to a
28
+ properly formatted bullet list with human-readable link texts.
29
+
30
+ Args:
31
+ text: The markdown text to process.
32
+
33
+ Returns:
34
+ The text with consecutive links formatted as a bullet list.
35
+ """
36
+ # Pattern to match consecutive markdown links: [text](url)[text](url)
37
+ # This regex matches two or more consecutive links
38
+ consecutive_links_pattern = re.compile(
39
+ r"(\[[^\]]+\]\([^)]+\))(\[[^\]]+\]\([^)]+\))+", re.MULTILINE
40
+ )
41
+
42
+ def format_links_match(match: re.Match[str]) -> str:
43
+ """Convert matched consecutive links to a bullet list."""
44
+ full_match = match.group(0)
45
+
46
+ # Extract all individual links from the match
47
+ link_pattern = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
48
+ links = link_pattern.findall(full_match)
49
+
50
+ if len(links) < MIN_LINKS_FOR_LIST_FORMAT:
51
+ return full_match
52
+
53
+ # Format as a bullet list with proper spacing
54
+ formatted_links = "\n\n**Quellen:**\n"
55
+ for link_text, url in links:
56
+ formatted_links += f"- [{link_text}]({url})\n"
57
+
58
+ return formatted_links
59
+
60
+ return consecutive_links_pattern.sub(format_links_match, text)
61
+
17
62
 
18
63
  class ResponseAccumulator:
19
64
  """
@@ -63,6 +108,8 @@ class ResponseAccumulator:
63
108
  if chunk.type == ChunkType.TEXT:
64
109
  if self.messages and self.messages[-1].type == MessageType.ASSISTANT:
65
110
  self.messages[-1].text += chunk.text
111
+ # Extract citations from metadata and add as annotations
112
+ self._extract_citations_to_annotations(chunk)
66
113
 
67
114
  elif chunk.type in (ChunkType.THINKING, ChunkType.THINKING_RESULT):
68
115
  self._handle_reasoning_chunk(chunk)
@@ -79,10 +126,18 @@ class ResponseAccumulator:
79
126
 
80
127
  elif chunk.type == ChunkType.COMPLETION:
81
128
  self.show_thinking = False
129
+ # Post-process message text to format consecutive links as list
130
+ self._format_message_links()
82
131
 
83
132
  elif chunk.type == ChunkType.AUTH_REQUIRED:
84
133
  self._handle_auth_required_chunk(chunk)
85
134
 
135
+ elif chunk.type == ChunkType.PROCESSING:
136
+ self._handle_processing_chunk(chunk)
137
+
138
+ elif chunk.type == ChunkType.ANNOTATION:
139
+ self._handle_annotation_chunk(chunk)
140
+
86
141
  elif chunk.type == ChunkType.ERROR:
87
142
  # We append it to the message text if it's not a hard error,
88
143
  # or creates a new message?
@@ -254,6 +309,13 @@ class ResponseAccumulator:
254
309
  item.status = status
255
310
  item.result = result
256
311
  item.error = error
312
+ # Also update tool_name from result if item is missing it
313
+ if (
314
+ display_name
315
+ and display_name != "Unknown"
316
+ and (not item.tool_name or item.tool_name == "Unknown")
317
+ ):
318
+ item.tool_name = display_name
257
319
  elif chunk.type == ChunkType.ACTION:
258
320
  item.text += f"\n---\nAktion: {chunk.text}"
259
321
 
@@ -273,3 +335,103 @@ class ResponseAccumulator:
273
335
  self.pending_auth_server_name = chunk.chunk_metadata.get("server_name", "")
274
336
  self.pending_auth_url = chunk.chunk_metadata.get("auth_url", "")
275
337
  self.auth_required = True
338
+
339
+ def _handle_processing_chunk(self, chunk: Chunk) -> None:
340
+ """Handle file processing progress chunks."""
341
+ status = chunk.chunk_metadata.get("status", "")
342
+
343
+ # Skip empty/skipped chunks (used for signaling completion without UI)
344
+ if not chunk.text or status == "skipped":
345
+ return
346
+
347
+ # Show thinking panel when processing
348
+ self.show_thinking = True
349
+ self.current_activity = chunk.text
350
+
351
+ # Determine item status based on metadata
352
+ if status == "completed":
353
+ item_status = ThinkingStatus.COMPLETED
354
+ elif status in ("failed", "timeout"):
355
+ item_status = ThinkingStatus.ERROR
356
+ else:
357
+ item_status = ThinkingStatus.IN_PROGRESS
358
+
359
+ # Use a single processing item that gets updated
360
+ item = self._get_or_create_thinking_item(
361
+ "file_processing",
362
+ ThinkingType.PROCESSING,
363
+ text=chunk.text,
364
+ status=item_status,
365
+ tool_name="Dateiverarbeitung",
366
+ )
367
+
368
+ item.text = chunk.text
369
+ item.status = item_status
370
+
371
+ # Store error if present
372
+ if status in ("failed", "timeout"):
373
+ item.error = chunk.chunk_metadata.get("error", chunk.text)
374
+
375
+ def _handle_annotation_chunk(self, chunk: Chunk) -> None:
376
+ """Handle file annotation/citation chunks."""
377
+ if not self.messages:
378
+ return
379
+
380
+ last_message = self.messages[-1]
381
+ if last_message.type != MessageType.ASSISTANT:
382
+ return
383
+
384
+ # Extract annotation text (filename or source reference)
385
+ annotation_text = chunk.text
386
+ if annotation_text and annotation_text not in last_message.annotations:
387
+ last_message.annotations.append(annotation_text)
388
+
389
+ def _extract_citations_to_annotations(self, chunk: Chunk) -> None:
390
+ """Extract citations from TEXT chunk metadata and add as annotations."""
391
+ citations_json = chunk.chunk_metadata.get("citations")
392
+ if not citations_json:
393
+ return
394
+
395
+ if not self.messages:
396
+ return
397
+
398
+ last_message = self.messages[-1]
399
+ if last_message.type != MessageType.ASSISTANT:
400
+ return
401
+
402
+ try:
403
+ citations = json.loads(citations_json)
404
+ except json.JSONDecodeError:
405
+ logger.warning("Failed to parse citations JSON: %s", citations_json)
406
+ return
407
+
408
+ max_citation_length = 50
409
+ for citation in citations:
410
+ # Prefer document_title, fall back to cited_text excerpt
411
+ annotation_text = citation.get("document_title")
412
+ if not annotation_text:
413
+ cited_text = citation.get("cited_text", "")
414
+ # Use first N chars of cited_text as fallback
415
+ if len(cited_text) > max_citation_length:
416
+ annotation_text = cited_text[:max_citation_length] + "..."
417
+ else:
418
+ annotation_text = cited_text
419
+
420
+ if annotation_text and annotation_text not in last_message.annotations:
421
+ last_message.annotations.append(annotation_text)
422
+
423
+ def _format_message_links(self) -> None:
424
+ """Format consecutive markdown links in the last message as a bullet list.
425
+
426
+ This post-processes the accumulated message text to improve readability
427
+ when the LLM returns multiple links concatenated without proper spacing.
428
+ """
429
+ if not self.messages:
430
+ return
431
+
432
+ last_message = self.messages[-1]
433
+ if last_message.type != MessageType.ASSISTANT:
434
+ return
435
+
436
+ if last_message.text:
437
+ last_message.text = _format_consecutive_links_as_list(last_message.text)
@@ -0,0 +1,89 @@
1
+ """System Prompt Builder service.
2
+
3
+ Provides unified system prompt construction with MCP tool injection
4
+ for all AI processors.
5
+ """
6
+
7
+ import logging
8
+
9
+ from appkit_assistant.backend.system_prompt_cache import get_system_prompt
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SystemPromptBuilder:
15
+ """Service for building system prompts with MCP tool injection."""
16
+
17
+ # Header for MCP tool selection guidelines
18
+ MCP_SECTION_HEADER = (
19
+ "### Tool-Auswahlrichtlinien (Einbettung externer Beschreibungen)"
20
+ )
21
+
22
+ def _build_mcp_section(self, mcp_prompt: str) -> str:
23
+ """Build the MCP tool selection section.
24
+
25
+ Args:
26
+ mcp_prompt: The MCP tool prompts from servers
27
+
28
+ Returns:
29
+ Formatted MCP section or empty string
30
+ """
31
+ if not mcp_prompt:
32
+ return ""
33
+ return f"{self.MCP_SECTION_HEADER}\n{mcp_prompt}"
34
+
35
+ async def build(self, mcp_prompt: str = "") -> str:
36
+ """Build the complete system prompt with MCP section.
37
+
38
+ Retrieves the base system prompt from cache and injects
39
+ the MCP tool prompts via the {mcp_prompts} placeholder.
40
+
41
+ Args:
42
+ mcp_prompt: Optional MCP tool prompts from servers
43
+
44
+ Returns:
45
+ Complete formatted system prompt
46
+ """
47
+ # Get base system prompt from cache
48
+ system_prompt_template = await get_system_prompt()
49
+
50
+ # Build MCP section
51
+ mcp_section = self._build_mcp_section(mcp_prompt)
52
+
53
+ # Format template with MCP prompts
54
+ return system_prompt_template.format(mcp_prompts=mcp_section)
55
+
56
+ async def build_with_prefix(
57
+ self,
58
+ mcp_prompt: str = "",
59
+ prefix: str = "",
60
+ ) -> str:
61
+ """Build system prompt with optional prefix.
62
+
63
+ Args:
64
+ mcp_prompt: Optional MCP tool prompts
65
+ prefix: Optional prefix to prepend (e.g., for Gemini's leading newlines)
66
+
67
+ Returns:
68
+ Complete formatted system prompt with prefix
69
+ """
70
+ prompt = await self.build(mcp_prompt)
71
+ if prefix:
72
+ return f"{prefix}{prompt}"
73
+ return prompt
74
+
75
+
76
+ # Singleton instance
77
+ _system_prompt_builder: SystemPromptBuilder | None = None
78
+
79
+
80
+ def get_system_prompt_builder() -> SystemPromptBuilder:
81
+ """Get or create the system prompt builder singleton.
82
+
83
+ Returns:
84
+ The SystemPromptBuilder instance
85
+ """
86
+ global _system_prompt_builder
87
+ if _system_prompt_builder is None:
88
+ _system_prompt_builder = SystemPromptBuilder()
89
+ return _system_prompt_builder
@@ -1,14 +1,16 @@
1
1
  import logging
2
2
  import uuid
3
3
 
4
- from appkit_assistant.backend.model_manager import ModelManager
5
- from appkit_assistant.backend.models import (
4
+ from appkit_assistant.backend.database.models import (
6
5
  AssistantThread,
6
+ )
7
+ from appkit_assistant.backend.database.repositories import thread_repo
8
+ from appkit_assistant.backend.model_manager import ModelManager
9
+ from appkit_assistant.backend.schemas import (
7
10
  Message,
8
11
  ThreadModel,
9
12
  ThreadStatus,
10
13
  )
11
- from appkit_assistant.backend.repositories import thread_repo
12
14
  from appkit_commons.database.session import get_asyncdb_session
13
15
 
14
16
  logger = logging.getLogger(__name__)
@@ -1,9 +1,9 @@
1
1
  import asyncio
2
2
  import logging
3
3
  from datetime import UTC, datetime, timedelta
4
- from typing import Final
4
+ from typing import Final, Self
5
5
 
6
- from appkit_assistant.backend.repositories import system_prompt_repo
6
+ from appkit_assistant.backend.database.repositories import system_prompt_repo
7
7
  from appkit_commons.database.session import get_asyncdb_session
8
8
 
9
9
  logger = logging.getLogger(__name__)
@@ -25,7 +25,7 @@ class SystemPromptCache:
25
25
  _instance: "SystemPromptCache | None" = None
26
26
  _lock: asyncio.Lock = asyncio.Lock()
27
27
 
28
- def __new__(cls) -> "SystemPromptCache":
28
+ def __new__(cls) -> Self:
29
29
  """Ensure singleton pattern."""
30
30
  if cls._instance is None:
31
31
  cls._instance = super().__new__(cls)
@@ -1,16 +1,19 @@
1
- from appkit_assistant.backend.models import Suggestion
2
1
  from appkit_assistant.components.composer import composer
2
+ from appkit_assistant.components.file_manager import file_manager
3
3
  from appkit_assistant.components.thread import Assistant
4
4
  from appkit_assistant.components.message import MessageComponent
5
- from appkit_assistant.backend.models import (
5
+ from appkit_assistant.backend.database.models import (
6
+ MCPServer,
7
+ ThreadStatus,
8
+ )
9
+ from appkit_assistant.backend.schemas import (
6
10
  AIModel,
7
11
  Chunk,
8
12
  ChunkType,
9
- MCPServer,
10
13
  Message,
11
14
  MessageType,
15
+ Suggestion,
12
16
  ThreadModel,
13
- ThreadStatus,
14
17
  UploadedFile,
15
18
  )
16
19
  from appkit_assistant.state.thread_list_state import ThreadListState
@@ -34,5 +37,6 @@ __all__ = [
34
37
  "ThreadStatus",
35
38
  "UploadedFile",
36
39
  "composer",
40
+ "file_manager",
37
41
  "mcp_servers_table",
38
42
  ]
@@ -3,7 +3,7 @@ from collections.abc import Callable
3
3
  import reflex as rx
4
4
 
5
5
  import appkit_mantine as mn
6
- from appkit_assistant.backend.models import UploadedFile
6
+ from appkit_assistant.backend.schemas import UploadedFile
7
7
  from appkit_assistant.components.tools_modal import tools_popover
8
8
  from appkit_assistant.state.thread_state import ThreadState
9
9
 
@@ -67,11 +67,14 @@ def submit() -> rx.Component:
67
67
  ),
68
68
  content="Stoppen",
69
69
  ),
70
- rx.button(
71
- rx.icon("arrow-right", size=18),
72
- id="composer-submit",
73
- name="composer_submit",
74
- type="submit",
70
+ rx.tooltip(
71
+ rx.button(
72
+ rx.icon("arrow-right", size=18),
73
+ id="composer-submit",
74
+ name="composer_submit",
75
+ type="submit",
76
+ ),
77
+ content="Absenden",
75
78
  ),
76
79
  )
77
80
 
@@ -137,36 +140,41 @@ def file_upload(show: bool = False) -> rx.Component:
137
140
  show & ThreadState.selected_model_supports_attachments,
138
141
  rx.tooltip(
139
142
  rx.upload.root(
140
- rx.box(
141
- rx.icon("paperclip", size=18, color=rx.color("gray", 9)),
142
- cursor="pointer",
143
- padding="8px",
144
- border_radius="8px",
145
- _hover={"background": rx.color("gray", 3)},
143
+ rx.tooltip(
144
+ rx.button(
145
+ rx.icon("paperclip", size=17),
146
+ cursor="pointer",
147
+ variant="ghost",
148
+ padding="8px",
149
+ ),
150
+ content=(
151
+ "Dateien hochladen (max. "
152
+ f"{ThreadState.max_files_per_thread}, "
153
+ f"{ThreadState.max_file_size_mb}MB pro Datei)"
154
+ ),
146
155
  ),
147
156
  id="composer_file_upload",
148
157
  accept={
149
- # "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
150
- # ".xlsx"
151
- # ],
152
- # "text/csv": [".csv"],
153
- # "application/vnd.openxmlformats-officedocument."
154
- # "wordprocessingml.document": [".docx"],
155
- # "application/vnd.openxmlformats-officedocument."
156
- # "presentationml.presentation": [".pptx"],
157
- # "text/markdown": [".md"],
158
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [ # noqa: E501
159
+ ".xlsx"
160
+ ],
161
+ "text/csv": [".csv"],
162
+ "application/vnd.openxmlformats-officedocument."
163
+ "wordprocessingml.document": [".docx"],
164
+ "application/vnd.openxmlformats-officedocument."
165
+ "presentationml.presentation": [".pptx"],
166
+ "text/markdown": [".md"],
158
167
  "application/pdf": [".pdf"],
159
168
  "image/png": [".png"],
160
169
  "image/jpeg": [".jpg", ".jpeg"],
161
170
  },
162
171
  multiple=True,
163
- max_files=5,
164
- max_size=5 * 1024 * 1024,
172
+ max_size=ThreadState.max_file_size_mb * 1024 * 1024,
165
173
  on_drop=ThreadState.handle_upload(
166
174
  rx.upload_files(upload_id="composer_file_upload")
167
175
  ),
168
176
  ),
169
- content="Dateien hochladen (max. 5, 5MB pro Datei)",
177
+ content=f"Dateien hochladen (max. {ThreadState.max_files_per_thread}, {ThreadState.max_file_size_mb}MB pro Datei)",
170
178
  ),
171
179
  rx.fragment(),
172
180
  )
@@ -200,11 +208,36 @@ def choose_model(show: bool = False) -> rx.Component | None:
200
208
  )
201
209
 
202
210
 
211
+ def web_search_toggle() -> rx.Component:
212
+ """Render web search toggle button."""
213
+ return rx.cond(
214
+ ThreadState.selected_model_supports_search,
215
+ rx.tooltip(
216
+ rx.button(
217
+ rx.icon("globe", size=17),
218
+ cursor="pointer",
219
+ variant=rx.cond(ThreadState.web_search_enabled, "solid", "ghost"),
220
+ color_scheme=rx.cond(ThreadState.web_search_enabled, "blue", "accent"),
221
+ padding="8px",
222
+ margin_right=rx.cond(
223
+ ThreadState.selected_model_supports_attachments, "6px", "14px"
224
+ ),
225
+ margin_left="-6px",
226
+ on_click=ThreadState.toggle_web_search,
227
+ type="button",
228
+ ),
229
+ content="Websuche aktivieren",
230
+ ),
231
+ rx.fragment(),
232
+ )
233
+
234
+
203
235
  def tools(show: bool = False) -> rx.Component:
204
236
  """Render tools button with conditional visibility."""
205
237
  return rx.cond(
206
238
  show,
207
239
  rx.hstack(
240
+ web_search_toggle(),
208
241
  tools_popover(),
209
242
  spacing="1",
210
243
  align="center",
@@ -220,6 +253,7 @@ def clear(show: bool = True) -> rx.Component | None:
220
253
  return rx.tooltip(
221
254
  rx.button(
222
255
  rx.icon("paintbrush", size=17),
256
+ cursor="pointer",
223
257
  variant="ghost",
224
258
  padding="8px",
225
259
  on_click=ThreadState.clear,
@@ -244,6 +278,7 @@ class ComposerComponent(rx.ComponentNamespace):
244
278
  choose_model = staticmethod(choose_model)
245
279
  clear = staticmethod(clear)
246
280
  file_upload = staticmethod(file_upload)
281
+ web_search_toggle = staticmethod(web_search_toggle)
247
282
  input = staticmethod(composer_input)
248
283
  selected_files_row = staticmethod(selected_files_row)
249
284
  submit = staticmethod(submit)