appkit-assistant 0.16.3__py3-none-any.whl → 0.17.1__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.
@@ -14,10 +14,12 @@ import json
14
14
  import logging
15
15
  import uuid
16
16
  from collections.abc import AsyncGenerator
17
+ from datetime import UTC, datetime
17
18
  from typing import Any
18
19
 
19
20
  import reflex as rx
20
21
 
22
+ from appkit_assistant.backend import file_manager
21
23
  from appkit_assistant.backend.model_manager import ModelManager
22
24
  from appkit_assistant.backend.models import (
23
25
  AIModel,
@@ -31,10 +33,11 @@ from appkit_assistant.backend.models import (
31
33
  ThinkingType,
32
34
  ThreadModel,
33
35
  ThreadStatus,
36
+ UploadedFile,
34
37
  )
35
38
  from appkit_assistant.backend.repositories import mcp_server_repo
39
+ from appkit_assistant.backend.response_accumulator import ResponseAccumulator
36
40
  from appkit_assistant.backend.services.thread_service import ThreadService
37
- from appkit_assistant.logic.response_accumulator import ResponseAccumulator
38
41
  from appkit_assistant.state.thread_list_state import ThreadListState
39
42
  from appkit_commons.database.session import get_asyncdb_session
40
43
  from appkit_user.authentication.states import UserSession
@@ -69,6 +72,13 @@ class ThreadState(rx.State):
69
72
  thinking_expanded: bool = False
70
73
  current_activity: str = ""
71
74
 
75
+ # File upload state
76
+ uploaded_files: list[UploadedFile] = []
77
+
78
+ # Editing state
79
+ editing_message_id: str | None = None
80
+ edited_message_content: str = ""
81
+
72
82
  # Internal logic helper (not reactive)
73
83
  @property
74
84
  def _thread_service(self) -> ThreadService:
@@ -97,6 +107,7 @@ class ThreadState(rx.State):
97
107
  _initialized: bool = False
98
108
  _current_user_id: str = ""
99
109
  _skip_user_message: bool = False # Skip adding user message (for OAuth resend)
110
+ _pending_file_cleanup: list[str] = [] # Files to delete after processing
100
111
 
101
112
  # -------------------------------------------------------------------------
102
113
  # Computed properties
@@ -135,6 +146,14 @@ class ThreadState(rx.State):
135
146
  model = ModelManager().get_model(self.selected_model)
136
147
  return model.supports_tools if model else False
137
148
 
149
+ @rx.var
150
+ def selected_model_supports_attachments(self) -> bool:
151
+ """Check if the currently selected model supports attachments."""
152
+ if not self.selected_model:
153
+ return False
154
+ model = ModelManager().get_model(self.selected_model)
155
+ return model.supports_attachments if model else False
156
+
138
157
  @rx.var
139
158
  def get_unique_reasoning_sessions(self) -> list[str]:
140
159
  """Get unique reasoning session IDs."""
@@ -432,11 +451,131 @@ class ThreadState(rx.State):
432
451
  self.thinking_items = []
433
452
  self.image_chunks = []
434
453
  self.show_thinking = False
454
+ self._clear_uploaded_files()
455
+
456
+ # -------------------------------------------------------------------------
457
+ # File upload handling
458
+ # -------------------------------------------------------------------------
459
+
460
+ @rx.event
461
+ async def handle_upload(self, files: list[rx.UploadFile]) -> None:
462
+ """Handle uploaded files from the browser.
463
+
464
+ Moves files to user-specific directory and adds them to state.
465
+ """
466
+ user_session: UserSession = await self.get_state(UserSession)
467
+ user_id = user_session.user.user_id if user_session.user else "anonymous"
468
+
469
+ for upload_file in files:
470
+ try:
471
+ # Save uploaded file to disk first
472
+ upload_data = await upload_file.read()
473
+ temp_path = rx.get_upload_dir() / upload_file.filename
474
+ temp_path.parent.mkdir(parents=True, exist_ok=True)
475
+ temp_path.write_bytes(upload_data)
476
+
477
+ # Move to user directory
478
+ final_path = file_manager.move_to_user_directory(
479
+ str(temp_path), str(user_id)
480
+ )
481
+ file_size = file_manager.get_file_size(final_path)
482
+
483
+ uploaded = UploadedFile(
484
+ filename=upload_file.filename,
485
+ file_path=final_path,
486
+ size=file_size,
487
+ )
488
+ self.uploaded_files = [*self.uploaded_files, uploaded]
489
+ logger.info("Uploaded file: %s", upload_file.filename)
490
+ except Exception as e:
491
+ logger.error("Failed to upload file %s: %s", upload_file.filename, e)
492
+
493
+ @rx.event
494
+ def remove_file_from_prompt(self, file_path: str) -> None:
495
+ """Remove an uploaded file from the prompt."""
496
+ # Delete the file from disk
497
+ file_manager.cleanup_uploaded_files([file_path])
498
+ # Remove from state
499
+ self.uploaded_files = [
500
+ f for f in self.uploaded_files if f.file_path != file_path
501
+ ]
502
+ logger.debug("Removed uploaded file: %s", file_path)
503
+
504
+ def _clear_uploaded_files(self) -> None:
505
+ """Clear all uploaded files from state and disk."""
506
+ if not self.uploaded_files:
507
+ return
508
+ count = len(self.uploaded_files)
509
+ file_paths = [f.file_path for f in self.uploaded_files]
510
+ file_manager.cleanup_uploaded_files(file_paths)
511
+ self.uploaded_files = []
512
+ logger.debug("Cleared %d uploaded files", count)
435
513
 
436
514
  # -------------------------------------------------------------------------
437
515
  # Message processing
438
516
  # -------------------------------------------------------------------------
439
517
 
518
+ @rx.event
519
+ def set_editing_mode(self, message_id: str, content: str) -> None:
520
+ """Enable editing mode for a message."""
521
+ self.editing_message_id = message_id
522
+ self.edited_message_content = content
523
+
524
+ @rx.event
525
+ def set_edited_message_content(self, content: str) -> None:
526
+ """Set the content of the message currently being edited."""
527
+ self.edited_message_content = content
528
+
529
+ @rx.event
530
+ def cancel_edit(self) -> None:
531
+ """Cancel editing mode."""
532
+ self.editing_message_id = None
533
+ self.edited_message_content = ""
534
+
535
+ @rx.event(background=True)
536
+ async def submit_edited_message(self) -> AsyncGenerator[Any, Any]:
537
+ """Submit edited message."""
538
+ async with self:
539
+ content = self.edited_message_content.strip()
540
+ if len(content) < 1:
541
+ yield rx.toast.error(
542
+ "Nachricht darf nicht leer sein", position="top-right"
543
+ )
544
+ return
545
+
546
+ # Find message index
547
+ msg_index = -1
548
+ for i, m in enumerate(self.messages):
549
+ if m.id == self.editing_message_id:
550
+ msg_index = i
551
+ break
552
+
553
+ if msg_index == -1:
554
+ self.cancel_edit()
555
+ return
556
+
557
+ target_message = self.messages[msg_index]
558
+
559
+ # Update message
560
+ target_message.original_text = (
561
+ target_message.original_text or target_message.text
562
+ )
563
+ target_message.text = content
564
+
565
+ # Remove all messages AFTER this one
566
+ self.messages = self.messages[: msg_index + 1]
567
+
568
+ # Set prompt to bypass empty check in _begin_message_processing
569
+ self.prompt = content
570
+ self._skip_user_message = True
571
+
572
+ # Clear edit state
573
+ self.editing_message_id = None
574
+ self.edited_message_content = ""
575
+
576
+ # Trigger processing
577
+ await self._process_message()
578
+
440
579
  @rx.event(background=True)
441
580
  async def submit_message(self) -> AsyncGenerator[Any, Any]:
442
581
  """Submit a message and process the response."""
@@ -451,6 +590,76 @@ class ThreadState(rx.State):
451
590
  }
452
591
  """)
453
592
 
593
+ @rx.event(background=True)
594
+ async def delete_message(self, message_id: str) -> None:
595
+ """Delete a message from the conversation."""
596
+ async with self:
597
+ self.messages = [m for m in self.messages if m.id != message_id]
598
+ self._thread.messages = self.messages
599
+
600
+ if self._thread.state != ThreadStatus.NEW:
601
+ await self._thread_service.save_thread(
602
+ self._thread, self.current_user_id
603
+ )
604
+
605
+ @rx.event
606
+ def copy_message(self, text: str) -> list[Any]:
607
+ """Copy message text to clipboard."""
608
+ return [
609
+ rx.set_clipboard(text),
610
+ rx.toast.success("Nachricht kopiert"),
611
+ ]
612
+
613
+ @rx.event
614
+ def download_message(self, text: str, message_id: str) -> Any:
615
+ """Download message as markdown file."""
616
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
617
+ filename = (
618
+ f"message_{message_id}_{timestamp}.md"
619
+ if message_id
620
+ else f"message_{timestamp}.md"
621
+ )
622
+
623
+ # Use JavaScript to trigger download
624
+ return rx.call_script(f"""
625
+ const blob = new Blob([{json.dumps(text)}], {{type: 'text/markdown'}});
626
+ const url = URL.createObjectURL(blob);
627
+ const a = document.createElement('a');
628
+ a.href = url;
629
+ a.download = '{filename}';
630
+ document.body.appendChild(a);
631
+ a.click();
632
+ document.body.removeChild(a);
633
+ URL.revokeObjectURL(url);
634
+ """)
635
+
636
+ @rx.event(background=True)
637
+ async def retry_message(self, message_id: str) -> None:
638
+ """Retry generating a message."""
639
+ async with self:
640
+ # Find message index
641
+ index = -1
642
+ for i, msg in enumerate(self.messages):
643
+ if msg.id == message_id:
644
+ index = i
645
+ break
646
+
647
+ if index == -1:
648
+ return
649
+
650
+ # Keep context up to this message
651
+ # effectively removing this message and everything after
652
+ self.messages = self.messages[:index]
653
+
654
+ # Set prompt to bypass check (content checks)
655
+ self.prompt = "Regenerate"
656
+
657
+ # Flag to skip adding a new user message
658
+ self._skip_user_message = True
659
+
660
+ # Trigger processing directly
661
+ await self._process_message()
662
+
454
663
  async def _process_message(self) -> None:
455
664
  """Process the current message and stream the response."""
456
665
  logger.debug("Processing message: %s", self.prompt)
@@ -458,7 +667,7 @@ class ThreadState(rx.State):
458
667
  start = await self._begin_message_processing()
459
668
  if not start:
460
669
  return
461
- current_prompt, selected_model, mcp_servers, is_new_thread = start
670
+ current_prompt, selected_model, mcp_servers, file_paths, is_new_thread = start
462
671
 
463
672
  processor = ModelManager().get_processor_for_model(selected_model)
464
673
  if not processor:
@@ -475,11 +684,16 @@ class ThreadState(rx.State):
475
684
  accumulator = ResponseAccumulator()
476
685
  accumulator.attach_messages_ref(self.messages)
477
686
 
687
+ # Clear uploaded files from UI and mark for cleanup after processing
688
+ self.uploaded_files = []
689
+ self._pending_file_cleanup = file_paths
690
+
478
691
  first_response_received = False
479
692
  try:
480
693
  async for chunk in processor.process(
481
694
  self.messages,
482
695
  selected_model,
696
+ files=file_paths or None,
483
697
  mcp_servers=mcp_servers,
484
698
  user_id=user_id,
485
699
  ):
@@ -506,7 +720,7 @@ class ThreadState(rx.State):
506
720
 
507
721
  async def _begin_message_processing(
508
722
  self,
509
- ) -> tuple[str, str, list[MCPServer], bool] | None:
723
+ ) -> tuple[str, str, list[MCPServer], list[str], bool] | None:
510
724
  """Prepare state for sending a message. Returns None if no-op."""
511
725
  async with self:
512
726
  current_prompt = self.prompt.strip()
@@ -523,12 +737,21 @@ class ThreadState(rx.State):
523
737
 
524
738
  is_new_thread = self._thread.state == ThreadStatus.NEW
525
739
 
740
+ # Capture file paths before clearing
741
+ file_paths = [f.file_path for f in self.uploaded_files]
742
+ # Capture filenames for message display
743
+ attachment_names = [f.filename for f in self.uploaded_files]
744
+
526
745
  # Add user message unless skipped (e.g., OAuth resend)
527
746
  if self._skip_user_message:
528
747
  self._skip_user_message = False
529
748
  else:
530
749
  self.messages.append(
531
- Message(text=current_prompt, type=MessageType.HUMAN)
750
+ Message(
751
+ text=current_prompt,
752
+ type=MessageType.HUMAN,
753
+ attachments=attachment_names,
754
+ )
532
755
  )
533
756
  # Always add assistant placeholder
534
757
  self.messages.append(Message(text="", type=MessageType.ASSISTANT))
@@ -540,7 +763,13 @@ class ThreadState(rx.State):
540
763
  return None
541
764
 
542
765
  mcp_servers = self.selected_mcp_servers
543
- return current_prompt, selected_model, mcp_servers, is_new_thread
766
+ return (
767
+ current_prompt,
768
+ selected_model,
769
+ mcp_servers,
770
+ file_paths,
771
+ is_new_thread,
772
+ )
544
773
 
545
774
  async def _stop_processing_with_error(self, error_msg: str) -> None:
546
775
  """Stop processing and show an error message."""
@@ -650,6 +879,11 @@ class ThreadState(rx.State):
650
879
  self.processing = False
651
880
  self.current_activity = ""
652
881
 
882
+ # Clean up uploaded files from disk
883
+ if self._pending_file_cleanup:
884
+ file_manager.cleanup_uploaded_files(self._pending_file_cleanup)
885
+ self._pending_file_cleanup = []
886
+
653
887
  def _handle_auth_required_from_accumulator(
654
888
  self, accumulator: ResponseAccumulator
655
889
  ) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appkit-assistant
3
- Version: 0.16.3
3
+ Version: 0.17.1
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
@@ -16,9 +16,12 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
17
  Classifier: Topic :: Software Development :: User Interfaces
18
18
  Requires-Python: >=3.13
19
+ Requires-Dist: anthropic>=0.40.0
19
20
  Requires-Dist: appkit-commons
20
21
  Requires-Dist: appkit-mantine
21
22
  Requires-Dist: appkit-ui
23
+ Requires-Dist: google-genai>=1.52.0
24
+ Requires-Dist: mcp>=1.0.0
22
25
  Requires-Dist: openai>=2.3.0
23
26
  Requires-Dist: reflex>=0.8.22
24
27
  Description-Content-Type: text/markdown
@@ -1,34 +1,39 @@
1
- appkit_assistant/configuration.py,sha256=3nBL-dEGYsvSnRDNpxtikZn4QMMkMlNbb4VqGOPolJI,346
1
+ appkit_assistant/configuration.py,sha256=Wpo3EuGWQrV0WIQnAhkj19PzgGkJoAde5ky-MA7kJwg,429
2
2
  appkit_assistant/pages.py,sha256=gDvBweUO2WjrhP1RE5AAkjL1_S-givWr3CkkGZKws_E,471
3
+ appkit_assistant/backend/file_manager.py,sha256=54SYphu6FsxbEYuMx8ohQiSAeY2gGDV1q3S6RZuNku0,3153
3
4
  appkit_assistant/backend/mcp_auth_service.py,sha256=lYQEe4yOZ48ear6dvcuOXsaOc6RClIBMsOOkV7SG5Aw,27768
4
5
  appkit_assistant/backend/model_manager.py,sha256=fmv3yP63LxDnne4vjT7IzETTI2aSxViC2FSUfHQajlk,4382
5
- appkit_assistant/backend/models.py,sha256=jNhJ6z8LKN6geJ6KTU60d10MVhWFcof6yJIVP18gtOs,7983
6
+ appkit_assistant/backend/models.py,sha256=GCFWUUPsVspcFEv9naQ8n2bMU8FzMNrmqm3VgW1Fqfw,8346
6
7
  appkit_assistant/backend/processor.py,sha256=LtkkC6v0idNtbuMm3Sw5J9zIjYqtNhtxYxjYefPSS9A,2135
7
8
  appkit_assistant/backend/repositories.py,sha256=R-7kYdxg4RWQrTEOU4tbcOEhJA_FlesWrt65UpItRSU,5547
9
+ appkit_assistant/backend/response_accumulator.py,sha256=BCK-Ut_Wmo7rhEqOb7QlqXO8TrtANjL80FbX_AvMl1Q,10056
8
10
  appkit_assistant/backend/system_prompt_cache.py,sha256=83OIyixeTb3HKOy3XIzPyTAE-G2JyqrfcG8xVeTS2Ls,5514
9
- appkit_assistant/backend/processors/lorem_ipsum_processor.py,sha256=j-MZhzibrtabzbGB2Pf4Xcdlr1TlTYWNRdE22LsDp9Q,4635
10
- appkit_assistant/backend/processors/openai_base.py,sha256=IQS4m375BOD_K0PBFOk4i7wL1z5MEiPFxbSmC-HBNgU,4414
11
- appkit_assistant/backend/processors/openai_chat_completion_processor.py,sha256=nTxouoXDU6VcQr8UhA2KiMNt60KvIwM8cH9Z8lo4dXY,4218
12
- appkit_assistant/backend/processors/openai_responses_processor.py,sha256=z4FxW7A9ysC0scyZ4gpvpoAsweYXSlMFDzGhJO5Lu2U,29094
11
+ appkit_assistant/backend/processors/claude_base.py,sha256=j0DhBn8EVAjW_bfCghXaEHyORO1raUNdQeemVWCKJlA,5376
12
+ appkit_assistant/backend/processors/claude_responses_processor.py,sha256=_quuN5FRsPSpQjaNIceN-Z66Fb3RnhXtcsS4gE0NrPA,32980
13
+ appkit_assistant/backend/processors/gemini_base.py,sha256=ijCa8-_xdddD6ms_pkUjCj5kHZOoQ1wfw-7AhhXu6vo,2286
14
+ appkit_assistant/backend/processors/gemini_responses_processor.py,sha256=V5jZ83a3vi60aFTtRnbwP2q1PyEGw1iB6dYne-4CgI4,26484
15
+ appkit_assistant/backend/processors/lorem_ipsum_processor.py,sha256=WR4s1RyOi5xgWVpre04xoxoMLIAINkpdvRCkSfNuoMc,4699
16
+ appkit_assistant/backend/processors/openai_base.py,sha256=TBQCZW7RxaPMag_DX_tpLyMpZ_PZ_KiO8_sFN49OCpk,4424
17
+ appkit_assistant/backend/processors/openai_chat_completion_processor.py,sha256=XFp_V9Yp4KcTmicbCe3fl_3G2tP5Ffm-xLVqW50HyQw,4629
18
+ appkit_assistant/backend/processors/openai_responses_processor.py,sha256=hysK7mlsG2t8SfltQpCvKA6MxpeIomRMGX-tSMaUshc,29492
13
19
  appkit_assistant/backend/processors/perplexity_processor.py,sha256=weHukv78MSCF_uSCKGSMpNYHsET9OB8IhpvUiMfPQ8A,3355
14
20
  appkit_assistant/backend/services/thread_service.py,sha256=LpM8ZZHt1o4MYEKzH_XPURSi3qS6p3pAQA53tOE53MU,4663
15
- appkit_assistant/components/__init__.py,sha256=5tzK5VjX9FGKK-qTUHLjr8-ohT4ykb4a-zC-I3yeRLY,916
16
- appkit_assistant/components/composer.py,sha256=F4VPxWp4P6fvTW4rQ7S-YWn0eje5c3jGsWrpC1aewss,3885
21
+ appkit_assistant/components/__init__.py,sha256=ptv0wShA4CHjgNpehlHqgoinCl-yyofjkV6rTTNIRHE,954
22
+ appkit_assistant/components/composer.py,sha256=jQNZd8Y0eQgbfC3d1AV1osRNnwfztEpXtrFp7aagXaY,7190
17
23
  appkit_assistant/components/composer_key_handler.py,sha256=KyZYyhxzFR8DH_7F_DrvTFNT6v5kG6JihlGTmCv2wv0,1028
18
24
  appkit_assistant/components/mcp_oauth.py,sha256=puLwxAhmF25BjnZMdJbKIfC6bFXK2D8LybOX0kD7Ri4,1737
19
25
  appkit_assistant/components/mcp_server_dialogs.py,sha256=afIImmhfrNyLmxDZBpCxHxvD8HKpDanIloLEC8dJgro,23444
20
26
  appkit_assistant/components/mcp_server_table.py,sha256=1dziN7hDDvE8Y3XcdIs0wUPv1H64kP9gRAEjgH9Yvzo,2323
21
- appkit_assistant/components/message.py,sha256=clqw5ISSO10NbbpD3OCfGo2CuefFl5-EIJXmlQEHncY,13554
27
+ appkit_assistant/components/message.py,sha256=SJcwWfSw5XxiSHtKirJUun9jkx3-ATpZ0AHLv82KwXc,19824
22
28
  appkit_assistant/components/system_prompt_editor.py,sha256=REl33zFmcpYRe9kxvFrBRYg40dV4L4FtVC_3ibLsmrU,2940
23
- appkit_assistant/components/thread.py,sha256=COl8PxMht4YIublIL-iOcvcJnWVpZfmLGifHC6dm7Rk,8421
29
+ appkit_assistant/components/thread.py,sha256=-KK1vcEmITR_oDGLbQhIa2WxRCVxEqel12m4haS7y9w,8461
24
30
  appkit_assistant/components/threadlist.py,sha256=1xVakSTQYi5-wgED3fTJVggeIjL_fkthehce0wKUYtM,4896
25
31
  appkit_assistant/components/tools_modal.py,sha256=12iiAVahy3j4JwjGfRlegVEa4ePhGsEu7Bq92JLn1ZI,3353
26
- appkit_assistant/logic/response_accumulator.py,sha256=85NKtIxpWVsI4rDkNAy_U34uavY8NoUEp9n2kO0ief4,8060
27
32
  appkit_assistant/state/mcp_oauth_state.py,sha256=6MofExrbOOEl_YUcUOqcSTN3h7KAaERI5IdVfXdVUVs,7669
28
33
  appkit_assistant/state/mcp_server_state.py,sha256=3AFvy53xx_eLTxw-LfJklPTgq4Ohqu4xs1QlLs-kU4U,11387
29
34
  appkit_assistant/state/system_prompt_state.py,sha256=zdnYrTnl7EszALRiodu6pcuQUd2tmtPG1eJ10j_OotI,7705
30
35
  appkit_assistant/state/thread_list_state.py,sha256=DEOR5Nklj1qfYaxSRMXCZdZRv2iq2Jb37JSg739_wL4,10250
31
- appkit_assistant/state/thread_state.py,sha256=rfbcdVTGcuC2MRnkTLP7x9fdUAHMK6NeS8cnhf621kg,30781
32
- appkit_assistant-0.16.3.dist-info/METADATA,sha256=OTRn32g9bgRtaFxt5p9x7r1WnC2rBD-DK9GszfUD-io,9403
33
- appkit_assistant-0.16.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
34
- appkit_assistant-0.16.3.dist-info/RECORD,,
36
+ appkit_assistant/state/thread_state.py,sha256=Hz7vRsBJ2cTV_xENuMoZBVSo2FGbR4uovdwiXF1zixk,39292
37
+ appkit_assistant-0.17.1.dist-info/METADATA,sha256=t0hdCrMbCpvbETpblS3bmLtJ0ltfMXhGI9MTOtnrzII,9498
38
+ appkit_assistant-0.17.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
39
+ appkit_assistant-0.17.1.dist-info/RECORD,,