supervertaler 1.9.116__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 (85) hide show
  1. Supervertaler.py +45720 -0
  2. modules/__init__.py +10 -0
  3. modules/ai_actions.py +964 -0
  4. modules/ai_attachment_manager.py +343 -0
  5. modules/ai_file_viewer_dialog.py +210 -0
  6. modules/autofingers_engine.py +466 -0
  7. modules/cafetran_docx_handler.py +379 -0
  8. modules/config_manager.py +469 -0
  9. modules/database_manager.py +1766 -0
  10. modules/database_migrations.py +417 -0
  11. modules/dejavurtf_handler.py +779 -0
  12. modules/document_analyzer.py +427 -0
  13. modules/docx_handler.py +689 -0
  14. modules/encoding_repair.py +319 -0
  15. modules/encoding_repair_Qt.py +393 -0
  16. modules/encoding_repair_ui.py +481 -0
  17. modules/feature_manager.py +350 -0
  18. modules/figure_context_manager.py +340 -0
  19. modules/file_dialog_helper.py +148 -0
  20. modules/find_replace.py +164 -0
  21. modules/find_replace_qt.py +457 -0
  22. modules/glossary_manager.py +433 -0
  23. modules/image_extractor.py +188 -0
  24. modules/keyboard_shortcuts_widget.py +571 -0
  25. modules/llm_clients.py +1211 -0
  26. modules/llm_leaderboard.py +737 -0
  27. modules/llm_superbench_ui.py +1401 -0
  28. modules/local_llm_setup.py +1104 -0
  29. modules/model_update_dialog.py +381 -0
  30. modules/model_version_checker.py +373 -0
  31. modules/mqxliff_handler.py +638 -0
  32. modules/non_translatables_manager.py +743 -0
  33. modules/pdf_rescue_Qt.py +1822 -0
  34. modules/pdf_rescue_tkinter.py +909 -0
  35. modules/phrase_docx_handler.py +516 -0
  36. modules/project_home_panel.py +209 -0
  37. modules/prompt_assistant.py +357 -0
  38. modules/prompt_library.py +689 -0
  39. modules/prompt_library_migration.py +447 -0
  40. modules/quick_access_sidebar.py +282 -0
  41. modules/ribbon_widget.py +597 -0
  42. modules/sdlppx_handler.py +874 -0
  43. modules/setup_wizard.py +353 -0
  44. modules/shortcut_manager.py +904 -0
  45. modules/simple_segmenter.py +128 -0
  46. modules/spellcheck_manager.py +727 -0
  47. modules/statuses.py +207 -0
  48. modules/style_guide_manager.py +315 -0
  49. modules/superbench_ui.py +1319 -0
  50. modules/superbrowser.py +325 -0
  51. modules/supercleaner.py +600 -0
  52. modules/supercleaner_ui.py +444 -0
  53. modules/superdocs.py +19 -0
  54. modules/superdocs_viewer_qt.py +382 -0
  55. modules/superlookup.py +248 -0
  56. modules/tag_cleaner.py +260 -0
  57. modules/tag_manager.py +333 -0
  58. modules/term_extractor.py +270 -0
  59. modules/termbase_entry_editor.py +842 -0
  60. modules/termbase_import_export.py +488 -0
  61. modules/termbase_manager.py +1060 -0
  62. modules/termview_widget.py +1161 -0
  63. modules/theme_manager.py +499 -0
  64. modules/tm_editor_dialog.py +99 -0
  65. modules/tm_manager_qt.py +1280 -0
  66. modules/tm_metadata_manager.py +545 -0
  67. modules/tmx_editor.py +1461 -0
  68. modules/tmx_editor_qt.py +2784 -0
  69. modules/tmx_generator.py +284 -0
  70. modules/tracked_changes.py +900 -0
  71. modules/trados_docx_handler.py +430 -0
  72. modules/translation_memory.py +670 -0
  73. modules/translation_results_panel.py +2134 -0
  74. modules/translation_services.py +282 -0
  75. modules/unified_prompt_library.py +656 -0
  76. modules/unified_prompt_manager_qt.py +3727 -0
  77. modules/voice_commands.py +920 -0
  78. modules/voice_dictation.py +477 -0
  79. modules/voice_dictation_lite.py +249 -0
  80. supervertaler-1.9.116.dist-info/METADATA +804 -0
  81. supervertaler-1.9.116.dist-info/RECORD +85 -0
  82. supervertaler-1.9.116.dist-info/WHEEL +5 -0
  83. supervertaler-1.9.116.dist-info/entry_points.txt +2 -0
  84. supervertaler-1.9.116.dist-info/licenses/LICENSE +21 -0
  85. supervertaler-1.9.116.dist-info/top_level.txt +2 -0
@@ -0,0 +1,3727 @@
1
+ """
2
+ Unified Prompt Manager Module - Qt Edition
3
+ Simplified 2-Layer Architecture:
4
+
5
+ 1. System Prompts (in Settings) - mode-specific, auto-selected based on document type
6
+ 2. Prompt Library (main UI) - unified workspace with folders, favorites, multi-attach
7
+
8
+ This replaces the old 4-layer system (System/Domain/Project/Style Guides).
9
+ """
10
+
11
+ import os
12
+ import json
13
+ from pathlib import Path
14
+ from datetime import datetime
15
+ from typing import List, Dict, Optional
16
+ from PyQt6.QtWidgets import (
17
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTreeWidget, QTreeWidgetItem,
18
+ QTextEdit, QPlainTextEdit, QSplitter, QGroupBox, QMessageBox, QFileDialog,
19
+ QInputDialog, QLineEdit, QFrame, QMenu, QCheckBox, QSizePolicy, QScrollArea, QTabWidget,
20
+ QListWidget, QListWidgetItem, QStyledItemDelegate, QStyleOptionViewItem, QApplication, QDialog,
21
+ QAbstractItemView
22
+ )
23
+ from PyQt6.QtCore import Qt, QSettings, pyqtSignal, QThread, QSize, QRect, QRectF
24
+ from PyQt6.QtGui import QFont, QColor, QAction, QIcon, QPainter, QPen, QBrush, QPainterPath, QLinearGradient
25
+
26
+ from modules.unified_prompt_library import UnifiedPromptLibrary
27
+ from modules.llm_clients import LLMClient, load_api_keys
28
+ from modules.prompt_library_migration import migrate_prompt_library
29
+ from modules.ai_attachment_manager import AttachmentManager
30
+ from modules.ai_file_viewer_dialog import FileViewerDialog, FileRemoveConfirmDialog
31
+ from modules.ai_actions import AIActionSystem
32
+
33
+
34
+ class PromptLibraryTreeWidget(QTreeWidget):
35
+ """Tree widget that supports drag-and-drop moves for prompt files."""
36
+
37
+ def __init__(self, manager, parent=None):
38
+ super().__init__(parent)
39
+ self._manager = manager
40
+
41
+ self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
42
+ self.setDragEnabled(True)
43
+ self.setAcceptDrops(True)
44
+ self.setDropIndicatorShown(True)
45
+ self.setDefaultDropAction(Qt.DropAction.MoveAction)
46
+ self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
47
+
48
+ def dropEvent(self, event):
49
+ """Handle prompt/folder moves via filesystem operations."""
50
+ try:
51
+ selected = self.selectedItems()
52
+ if not selected:
53
+ event.ignore()
54
+ return
55
+
56
+ source_item = selected[0]
57
+ source_data = source_item.data(0, Qt.ItemDataRole.UserRole)
58
+ if not source_data:
59
+ event.ignore()
60
+ return
61
+
62
+ src_type = source_data.get('type')
63
+ src_path = source_data.get('path')
64
+ if src_type not in {'prompt', 'folder'} or not src_path:
65
+ event.ignore()
66
+ return
67
+
68
+ pos = event.position().toPoint() if hasattr(event, 'position') else event.pos()
69
+ target_item = self.itemAt(pos)
70
+ target_data = target_item.data(0, Qt.ItemDataRole.UserRole) if target_item else None
71
+
72
+ # Determine destination folder.
73
+ dest_folder = ''
74
+ if target_data:
75
+ if target_data.get('type') == 'folder':
76
+ dest_folder = target_data.get('path', '')
77
+ elif target_data.get('type') == 'prompt':
78
+ dest_folder = str(Path(target_data.get('path', '')).parent)
79
+ if dest_folder == '.':
80
+ dest_folder = ''
81
+ else:
82
+ # Special nodes like Favorites / Quick Run: ignore.
83
+ event.ignore()
84
+ return
85
+
86
+ moved = False
87
+ if src_type == 'prompt' and self._manager and hasattr(self._manager, '_move_prompt_to_folder'):
88
+ moved = self._manager._move_prompt_to_folder(src_path, dest_folder)
89
+
90
+ if src_type == 'folder' and self._manager and hasattr(self._manager, '_move_folder_to_folder'):
91
+ moved = self._manager._move_folder_to_folder(src_path, dest_folder)
92
+
93
+ if moved:
94
+ event.acceptProposedAction()
95
+ else:
96
+ event.ignore()
97
+
98
+ except Exception:
99
+ event.ignore()
100
+ return
101
+
102
+
103
+ class ChatMessageDelegate(QStyledItemDelegate):
104
+ """Custom delegate for rendering chat messages with proper bubble styling"""
105
+
106
+ def __init__(self, parent=None):
107
+ super().__init__(parent)
108
+ self.padding = 16
109
+ self.bubble_padding = 12
110
+ self.avatar_size = 28
111
+ self.avatar_margin = 8
112
+ self.max_bubble_width_ratio = 0.7 # 70% of available width
113
+
114
+ def _markdown_to_html(self, text: str, color: str = "#1a1a1a") -> str:
115
+ """Convert simple markdown to HTML for rich text rendering"""
116
+ import re
117
+
118
+ # Escape HTML special characters first
119
+ text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
120
+
121
+ # Convert markdown to HTML
122
+ # Bold: **text** or __text__
123
+ text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
124
+ text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
125
+
126
+ # Italic: *text* or _text_
127
+ text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
128
+ text = re.sub(r'_(.+?)_', r'<i>\1</i>', text)
129
+
130
+ # Code: `code`
131
+ text = re.sub(r'`(.+?)`', r'<code style="background-color: #f0f0f0; padding: 2px 4px; border-radius: 3px; font-family: Consolas, monospace;">\1</code>', text)
132
+
133
+ # Bullet points: lines starting with • or - or *
134
+ lines = text.split('\n')
135
+ html_lines = []
136
+ in_list = False
137
+
138
+ for line in lines:
139
+ stripped = line.strip()
140
+ if stripped.startswith('•') or stripped.startswith('- ') or (stripped.startswith('* ') and len(stripped) > 2):
141
+ if not in_list:
142
+ html_lines.append('<ul style="margin: 4px 0; padding-left: 20px;">')
143
+ in_list = True
144
+ content = stripped[2:].strip() if stripped.startswith('- ') or stripped.startswith('* ') else stripped[1:].strip()
145
+ html_lines.append(f'<li>{content}</li>')
146
+ else:
147
+ if in_list:
148
+ html_lines.append('</ul>')
149
+ in_list = False
150
+ if stripped:
151
+ html_lines.append(line)
152
+ else:
153
+ html_lines.append('<br/>')
154
+
155
+ if in_list:
156
+ html_lines.append('</ul>')
157
+
158
+ html_text = ''.join(html_lines)
159
+
160
+ # Wrap in styled div
161
+ return f'<div style="color: {color}; line-height: 1.4;">{html_text}</div>'
162
+
163
+ def sizeHint(self, option: QStyleOptionViewItem, index):
164
+ """Calculate size needed for this message"""
165
+ from PyQt6.QtGui import QTextDocument
166
+
167
+ message_data = index.data(Qt.ItemDataRole.UserRole)
168
+ if not message_data:
169
+ return QSize(0, 0)
170
+
171
+ role = message_data.get('role', 'system')
172
+ message = message_data.get('content', '')
173
+
174
+ # Calculate text width
175
+ width = option.rect.width() if option.rect.width() > 0 else 800
176
+ max_bubble_width = int(width * self.max_bubble_width_ratio)
177
+
178
+ font = QFont("Segoe UI", 10 if role != "system" else 9)
179
+
180
+ if role == "system":
181
+ # System messages are centered and smaller (with markdown formatting)
182
+ text_width = int(width * 0.8) - (self.bubble_padding * 2)
183
+
184
+ # Use QTextDocument to measure height with markdown
185
+ doc = QTextDocument()
186
+ doc.setDefaultFont(font)
187
+ doc.setHtml(self._markdown_to_html(message, "#5f6368"))
188
+ doc.setTextWidth(text_width)
189
+
190
+ text_height = doc.size().height()
191
+ height = text_height + self.bubble_padding + self.padding
192
+ else:
193
+ # User/assistant messages - use QTextDocument for accurate height with markdown
194
+ text_width = max_bubble_width - (self.bubble_padding * 2) - self.avatar_size - self.avatar_margin - self.padding
195
+
196
+ # Create text document to measure actual rendered height
197
+ doc = QTextDocument()
198
+ doc.setDefaultFont(font)
199
+ doc.setHtml(self._markdown_to_html(message, "#1a1a1a"))
200
+ doc.setTextWidth(text_width)
201
+
202
+ # Get actual document height
203
+ text_height = doc.size().height()
204
+ bubble_height = text_height + self.bubble_padding * 2
205
+ height = bubble_height + self.padding
206
+
207
+ return QSize(width, int(height))
208
+
209
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index):
210
+ """Paint the chat message bubble"""
211
+ painter.save()
212
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
213
+
214
+ message_data = index.data(Qt.ItemDataRole.UserRole)
215
+ if not message_data:
216
+ painter.restore()
217
+ return
218
+
219
+ role = message_data.get('role', 'system')
220
+ message = message_data.get('content', '')
221
+
222
+ rect = option.rect
223
+
224
+ if role == "user":
225
+ self._paint_user_message(painter, rect, message)
226
+ elif role == "assistant":
227
+ self._paint_assistant_message(painter, rect, message)
228
+ else: # system
229
+ self._paint_system_message(painter, rect, message)
230
+
231
+ painter.restore()
232
+
233
+ def _paint_user_message(self, painter: QPainter, rect: QRect, message: str):
234
+ """Paint user message (right-aligned, blue gradient)"""
235
+ from PyQt6.QtGui import QTextDocument
236
+
237
+ # Calculate dimensions
238
+ max_bubble_width = int(rect.width() * self.max_bubble_width_ratio)
239
+
240
+ # Calculate text size using QTextDocument for accurate height
241
+ font = QFont("Segoe UI", 10)
242
+ painter.setFont(font)
243
+
244
+ text_width = max_bubble_width - (self.bubble_padding * 2) - self.avatar_size - self.avatar_margin - self.padding
245
+
246
+ # Create text document to measure actual rendered size
247
+ doc = QTextDocument()
248
+ doc.setDefaultFont(font)
249
+ doc.setHtml(self._markdown_to_html(message, "white"))
250
+ doc.setTextWidth(text_width)
251
+
252
+ # Get actual document size
253
+ doc_size = doc.size()
254
+ bubble_width = min(doc_size.width() + self.bubble_padding * 2, max_bubble_width - self.avatar_size - self.avatar_margin)
255
+ bubble_height = doc_size.height() + self.bubble_padding * 2
256
+
257
+ # Position bubble on right side (leaving room for avatar)
258
+ bubble_x = rect.right() - bubble_width - self.avatar_size - self.avatar_margin - self.padding
259
+ bubble_y = rect.top() + self.padding // 2
260
+
261
+ # Draw bubble with gradient
262
+ bubble_rect = QRectF(bubble_x, bubble_y, bubble_width, bubble_height)
263
+ path = QPainterPath()
264
+ path.addRoundedRect(bubble_rect, 18, 18)
265
+
266
+ # Supervertaler blue gradient
267
+ gradient = QLinearGradient(bubble_rect.topLeft(), bubble_rect.bottomRight())
268
+ gradient.setColorAt(0, QColor("#5D7BFF"))
269
+ gradient.setColorAt(1, QColor("#4F6FFF"))
270
+
271
+ painter.fillPath(path, QBrush(gradient))
272
+
273
+ # Draw shadow
274
+ painter.setPen(QPen(QColor(93, 123, 255, 76), 0))
275
+ painter.drawRoundedRect(bubble_rect.adjusted(0, 2, 0, 2), 18, 18)
276
+
277
+ # Draw text with markdown formatting (reuse doc from above)
278
+ from PyQt6.QtGui import QAbstractTextDocumentLayout
279
+ text_draw_rect = bubble_rect.adjusted(
280
+ self.bubble_padding, self.bubble_padding,
281
+ -self.bubble_padding, -self.bubble_padding
282
+ )
283
+
284
+ # Translate painter to text position and draw
285
+ painter.save()
286
+ painter.translate(text_draw_rect.topLeft())
287
+ ctx = QAbstractTextDocumentLayout.PaintContext()
288
+ doc.documentLayout().draw(painter, ctx)
289
+ painter.restore()
290
+
291
+ # Draw avatar (right side)
292
+ avatar_x = rect.right() - self.avatar_size - self.padding
293
+ avatar_y = bubble_y
294
+ avatar_rect = QRectF(avatar_x, avatar_y, self.avatar_size, self.avatar_size)
295
+
296
+ # Avatar gradient background
297
+ avatar_gradient = QLinearGradient(avatar_rect.topLeft(), avatar_rect.bottomRight())
298
+ avatar_gradient.setColorAt(0, QColor("#667eea"))
299
+ avatar_gradient.setColorAt(1, QColor("#764ba2"))
300
+
301
+ painter.setBrush(QBrush(avatar_gradient))
302
+ painter.setPen(Qt.PenStyle.NoPen)
303
+ painter.drawEllipse(avatar_rect)
304
+
305
+ # Draw avatar emoji
306
+ painter.setPen(QPen(QColor("white")))
307
+ painter.setFont(QFont("Segoe UI Emoji", 13))
308
+ painter.drawText(avatar_rect, Qt.AlignmentFlag.AlignCenter, "👤")
309
+
310
+ def _paint_assistant_message(self, painter: QPainter, rect: QRect, message: str):
311
+ """Paint assistant message (left-aligned, gray)"""
312
+ from PyQt6.QtGui import QTextDocument
313
+
314
+ # Calculate dimensions
315
+ max_bubble_width = int(rect.width() * self.max_bubble_width_ratio)
316
+
317
+ # Calculate text size using QTextDocument for accurate height
318
+ font = QFont("Segoe UI", 10)
319
+ painter.setFont(font)
320
+
321
+ text_width = max_bubble_width - (self.bubble_padding * 2) - self.avatar_size - self.avatar_margin - self.padding
322
+
323
+ # Create text document to measure actual rendered size
324
+ doc = QTextDocument()
325
+ doc.setDefaultFont(font)
326
+ doc.setHtml(self._markdown_to_html(message, "#1a1a1a"))
327
+ doc.setTextWidth(text_width)
328
+
329
+ # Get actual document size
330
+ doc_size = doc.size()
331
+ bubble_width = min(doc_size.width() + self.bubble_padding * 2, max_bubble_width - self.avatar_size - self.avatar_margin)
332
+ bubble_height = doc_size.height() + self.bubble_padding * 2
333
+
334
+ # Position bubble on left side (leaving room for avatar)
335
+ bubble_x = rect.left() + self.avatar_size + self.avatar_margin + self.padding
336
+ bubble_y = rect.top() + self.padding // 2
337
+
338
+ # Draw bubble
339
+ bubble_rect = QRectF(bubble_x, bubble_y, bubble_width, bubble_height)
340
+ path = QPainterPath()
341
+ path.addRoundedRect(bubble_rect, 18, 18)
342
+
343
+ painter.fillPath(path, QBrush(QColor("#F5F5F7")))
344
+
345
+ # Draw border
346
+ painter.setPen(QPen(QColor("#E8E8EA"), 1))
347
+ painter.drawRoundedRect(bubble_rect, 18, 18)
348
+
349
+ # Draw shadow
350
+ painter.setPen(QPen(QColor(0, 0, 0, 20), 0))
351
+ painter.drawRoundedRect(bubble_rect.adjusted(0, 2, 0, 2), 18, 18)
352
+
353
+ # Draw text with markdown formatting (reuse doc from above)
354
+ from PyQt6.QtGui import QAbstractTextDocumentLayout
355
+ text_draw_rect = bubble_rect.adjusted(
356
+ self.bubble_padding, self.bubble_padding,
357
+ -self.bubble_padding, -self.bubble_padding
358
+ )
359
+
360
+ # Translate painter to text position and draw
361
+ painter.save()
362
+ painter.translate(text_draw_rect.topLeft())
363
+ ctx = QAbstractTextDocumentLayout.PaintContext()
364
+ doc.documentLayout().draw(painter, ctx)
365
+ painter.restore()
366
+
367
+ # Draw avatar (left side)
368
+ avatar_x = rect.left() + self.padding
369
+ avatar_y = bubble_y
370
+ avatar_rect = QRectF(avatar_x, avatar_y, self.avatar_size, self.avatar_size)
371
+
372
+ # Avatar gradient background
373
+ avatar_gradient = QLinearGradient(avatar_rect.topLeft(), avatar_rect.bottomRight())
374
+ avatar_gradient.setColorAt(0, QColor("#667eea"))
375
+ avatar_gradient.setColorAt(1, QColor("#764ba2"))
376
+
377
+ painter.setBrush(QBrush(avatar_gradient))
378
+ painter.setPen(Qt.PenStyle.NoPen)
379
+ painter.drawEllipse(avatar_rect)
380
+
381
+ # Draw avatar emoji
382
+ painter.setPen(QPen(QColor("white")))
383
+ painter.setFont(QFont("Segoe UI Emoji", 15))
384
+ painter.drawText(avatar_rect, Qt.AlignmentFlag.AlignCenter, "🤖")
385
+
386
+ def _paint_system_message(self, painter: QPainter, rect: QRect, message: str):
387
+ """Paint system message (centered, subtle, with markdown formatting)"""
388
+ from PyQt6.QtGui import QTextDocument, QAbstractTextDocumentLayout
389
+
390
+ # Create text document with markdown converted to HTML
391
+ font = QFont("Segoe UI", 9)
392
+ doc = QTextDocument()
393
+ doc.setDefaultFont(font)
394
+ doc.setHtml(self._markdown_to_html(message, "#5f6368"))
395
+
396
+ # Set max width (80% of available width)
397
+ max_width = int(rect.width() * 0.8) - (self.bubble_padding * 2)
398
+ doc.setTextWidth(max_width)
399
+
400
+ # Calculate bubble dimensions
401
+ text_height = doc.size().height()
402
+ bubble_width = max_width + self.bubble_padding * 2
403
+ bubble_height = text_height + self.bubble_padding
404
+
405
+ # Center horizontally
406
+ bubble_x = (rect.width() - bubble_width) / 2
407
+ bubble_y = rect.top() + self.padding // 2
408
+
409
+ # Draw bubble
410
+ bubble_rect = QRectF(bubble_x, bubble_y, bubble_width, bubble_height)
411
+ path = QPainterPath()
412
+ path.addRoundedRect(bubble_rect, 16, 16)
413
+
414
+ painter.fillPath(path, QBrush(QColor("#F8F9FA")))
415
+
416
+ # Draw border
417
+ painter.setPen(QPen(QColor("#E8EAED"), 1))
418
+ painter.drawRoundedRect(bubble_rect, 16, 16)
419
+
420
+ # Draw text with markdown formatting
421
+ text_draw_rect = bubble_rect.adjusted(
422
+ self.bubble_padding, self.bubble_padding // 2,
423
+ -self.bubble_padding, -self.bubble_padding // 2
424
+ )
425
+
426
+ # Translate painter to text position and draw
427
+ painter.save()
428
+ painter.translate(text_draw_rect.topLeft())
429
+ ctx = QAbstractTextDocumentLayout.PaintContext()
430
+ doc.documentLayout().draw(painter, ctx)
431
+ painter.restore()
432
+
433
+
434
+ class UnifiedPromptManagerQt:
435
+ """
436
+ Unified Prompt Manager - Single-tab interface with:
437
+ - Tree view with nested folders
438
+ - Favorites and QuickMenu
439
+ - Multi-attach capability
440
+ - Active prompt configuration panel
441
+ """
442
+
443
+ def __init__(self, parent_app, standalone=False):
444
+ """
445
+ Initialize Unified Prompt Manager
446
+
447
+ Args:
448
+ parent_app: Reference to main application (needs .user_data_path, .log() method)
449
+ standalone: If True, running standalone. If False, embedded in Supervertaler
450
+ """
451
+ self.parent_app = parent_app
452
+ self.standalone = standalone
453
+
454
+ # Get user_data path
455
+ if hasattr(parent_app, 'user_data_path'):
456
+ self.user_data_path = Path(parent_app.user_data_path)
457
+ else:
458
+ self.user_data_path = Path("user_data")
459
+
460
+ # Initialize logging
461
+ self.log = parent_app.log if hasattr(parent_app, 'log') else print
462
+
463
+ # Paths
464
+ self.prompt_library_dir = self.user_data_path / "Prompt_Library"
465
+ # Use Prompt_Library directly, not Prompt_Library/Library
466
+ self.unified_library_dir = self.prompt_library_dir
467
+
468
+ # Run migration if needed
469
+ self._check_and_migrate()
470
+
471
+ # Initialize unified prompt library
472
+ self.library = UnifiedPromptLibrary(
473
+ library_dir=str(self.unified_library_dir),
474
+ log_callback=self.log_message
475
+ )
476
+
477
+ # Load prompts
478
+ self.library.load_all_prompts()
479
+
480
+ # System Prompts (stored separately, loaded from settings/files)
481
+ self.system_templates = {}
482
+ self.current_mode = "single" # single, batch_docx, batch_bilingual
483
+ self._load_system_templates()
484
+
485
+ # UI will be created by create_tab()
486
+ self.main_widget = None
487
+ self.tree_widget = None
488
+ self.editor_content = None
489
+ self.active_config_widget = None
490
+
491
+ # AI Assistant state
492
+ self.llm_client: Optional[LLMClient] = None
493
+ self.attached_files: List[Dict] = [] # List of {path, name, content, type} - DEPRECATED, use attachment_manager
494
+ self.chat_history: List[Dict] = [] # List of {role, content, timestamp}
495
+ self.ai_conversation_file = self.user_data_path / "ai_assistant" / "conversation.json"
496
+ self._cached_document_markdown: Optional[str] = None # Cached markdown conversion of current document
497
+
498
+ # Initialize Attachment Manager
499
+ ai_assistant_dir = self.user_data_path / "AI_Assistant"
500
+ self.attachment_manager = AttachmentManager(
501
+ base_dir=str(ai_assistant_dir),
502
+ log_callback=self.log_message
503
+ )
504
+ # Set initial session based on current date/time
505
+ session_id = datetime.now().strftime("%Y%m%d")
506
+ self.attachment_manager.set_session(session_id)
507
+
508
+ # Initialize AI Action System (Phase 2)
509
+ self.ai_action_system = AIActionSystem(
510
+ prompt_library=self.library,
511
+ parent_app=self.parent_app,
512
+ log_callback=self.log_message
513
+ )
514
+
515
+ self._init_llm_client()
516
+ self._load_conversation_history()
517
+ self._load_persisted_attachments()
518
+
519
+ def _check_and_migrate(self):
520
+ """Check if migration is needed and perform it"""
521
+ try:
522
+ needs_migration = migrate_prompt_library(
523
+ str(self.prompt_library_dir),
524
+ log_callback=self.log_message
525
+ )
526
+
527
+ if needs_migration:
528
+ self.log_message("✓ Prompt library migration completed successfully")
529
+
530
+ except Exception as e:
531
+ self.log_message(f"⚠ Migration check failed: {e}")
532
+
533
+ def log_message(self, message):
534
+ """Log a message through parent app or print"""
535
+ self.log(message)
536
+
537
+ def create_tab(self, parent_widget):
538
+ """
539
+ Create the Prompt Manager tab UI with sub-tabs
540
+
541
+ Args:
542
+ parent_widget: Widget to add the tab to (will set its layout)
543
+ """
544
+ main_layout = QVBoxLayout(parent_widget)
545
+ main_layout.setContentsMargins(10, 10, 10, 10)
546
+ main_layout.setSpacing(5)
547
+
548
+ # Main header for Prompt Manager
549
+ header = self._create_main_header()
550
+ main_layout.addWidget(header, 0)
551
+
552
+ # Sub-tabs: Prompt Library and AI Assistant
553
+ self.sub_tabs = QTabWidget()
554
+ self.sub_tabs.tabBar().setFocusPolicy(Qt.FocusPolicy.NoFocus)
555
+ self.sub_tabs.tabBar().setDrawBase(False)
556
+ self.sub_tabs.setStyleSheet("QTabBar::tab { outline: 0; } QTabBar::tab:focus { outline: none; } QTabBar::tab:selected { border-bottom: 1px solid #2196F3; background-color: rgba(33, 150, 243, 0.08); }")
557
+
558
+ # Tab 1: Prompt Library
559
+ library_tab = self._create_prompt_library_tab()
560
+ self.sub_tabs.addTab(library_tab, "📚 Prompt Library")
561
+
562
+ # Tab 2: AI Assistant (placeholder for now)
563
+ assistant_tab = self._create_ai_assistant_tab()
564
+ self.sub_tabs.addTab(assistant_tab, "✨ AI Assistant")
565
+
566
+ # Connect tab change signal to update context
567
+ self.sub_tabs.currentChanged.connect(self._on_tab_changed)
568
+
569
+ main_layout.addWidget(self.sub_tabs, 1) # 1 = stretch
570
+
571
+ def _on_tab_changed(self, index):
572
+ """Handle tab change - update context when switching to AI Assistant"""
573
+ if index == 1: # AI Assistant tab
574
+ self._update_context_sidebar()
575
+
576
+ def refresh_context(self):
577
+ """
578
+ Public method to refresh AI Assistant context.
579
+ Call this from the main app when document/project changes.
580
+ """
581
+ # Reload cached document markdown from disk
582
+ if hasattr(self.parent_app, 'current_document_path') and self.parent_app.current_document_path:
583
+ doc_path = Path(self.parent_app.current_document_path)
584
+ # Try to load existing markdown
585
+ markdown_dir = self.user_data_path / "ai_assistant" / "current_document"
586
+ markdown_file = markdown_dir / f"{doc_path.stem}.md"
587
+ if markdown_file.exists():
588
+ try:
589
+ with open(markdown_file, 'r', encoding='utf-8') as f:
590
+ self._cached_document_markdown = f.read()
591
+ self.log_message(f"✓ Loaded cached markdown: {markdown_file.name}")
592
+ except Exception as e:
593
+ self.log_message(f"⚠ Failed to load cached markdown: {e}")
594
+ self._cached_document_markdown = None
595
+ else:
596
+ self._cached_document_markdown = None
597
+ else:
598
+ self._cached_document_markdown = None
599
+
600
+ self._update_context_sidebar()
601
+
602
+ def _create_main_header(self) -> QWidget:
603
+ """Create main Prompt Manager header"""
604
+ header_container = QWidget()
605
+ layout = QVBoxLayout(header_container)
606
+ layout.setContentsMargins(0, 0, 0, 0)
607
+ layout.setSpacing(5)
608
+
609
+ # Title
610
+ title = QLabel("🤖 Prompt Manager")
611
+ title.setStyleSheet("font-size: 16pt; font-weight: bold; color: #1976D2;")
612
+ layout.addWidget(title, 0)
613
+
614
+ # Description
615
+ desc = QLabel(
616
+ "Manage AI instructions and get AI assistance for your translation projects.\n"
617
+ "Create custom prompts, organize them in folders, and use AI to analyze documents."
618
+ )
619
+ desc.setWordWrap(True)
620
+ desc.setStyleSheet("color: #666; padding: 5px; background-color: #E3F2FD; border-radius: 3px;")
621
+ layout.addWidget(desc, 0)
622
+
623
+ return header_container
624
+
625
+ def _create_prompt_library_tab(self) -> QWidget:
626
+ """Create the Prompt Library sub-tab"""
627
+ tab = QWidget()
628
+ layout = QVBoxLayout(tab)
629
+ layout.setContentsMargins(0, 5, 0, 0)
630
+ layout.setSpacing(5)
631
+
632
+ # Main content: Horizontal splitter (left: config+buttons+tree | right: editor)
633
+ main_splitter = QSplitter(Qt.Orientation.Horizontal)
634
+ main_splitter.setHandleWidth(3)
635
+
636
+ # Left panel container (not a splitter - fixed layout)
637
+ left_panel = QWidget()
638
+ left_layout = QVBoxLayout(left_panel)
639
+ left_layout.setContentsMargins(0, 0, 0, 0)
640
+ left_layout.setSpacing(5)
641
+
642
+ # Active Configuration Panel (top of left)
643
+ config_group = self._create_active_config_panel()
644
+ config_group.setMinimumHeight(150)
645
+ left_layout.addWidget(config_group)
646
+
647
+ # Library Action Buttons (below Active Config, above tree)
648
+ library_buttons = self._create_library_buttons()
649
+ left_layout.addWidget(library_buttons)
650
+
651
+ # Prompt Library Tree (bottom of left)
652
+ tree_panel = self._create_library_tree_panel()
653
+ tree_panel.setMinimumHeight(200)
654
+ left_layout.addWidget(tree_panel, 1) # stretch factor 1 - tree expands
655
+
656
+ left_panel.setMinimumWidth(300)
657
+ main_splitter.addWidget(left_panel)
658
+
659
+ # Right: Editor only
660
+ editor_group = self._create_editor_panel()
661
+ editor_group.setMinimumWidth(400)
662
+ editor_group.setMinimumHeight(300)
663
+ main_splitter.addWidget(editor_group)
664
+
665
+ # Set main splitter proportions (40% left, 60% editor)
666
+ main_splitter.setSizes([400, 600])
667
+ main_splitter.setStretchFactor(0, 1)
668
+ main_splitter.setStretchFactor(1, 2)
669
+
670
+ layout.addWidget(main_splitter, 1)
671
+
672
+ # Load initial tree content
673
+ self._refresh_tree()
674
+
675
+ return tab
676
+
677
+ def _create_library_buttons(self) -> QWidget:
678
+ """Create action buttons for Prompt Library (between Active Config and tree)"""
679
+ container = QWidget()
680
+ btn_layout = QHBoxLayout(container)
681
+ btn_layout.setContentsMargins(0, 0, 0, 0)
682
+ btn_layout.setSpacing(5)
683
+
684
+ btn_new = QPushButton("+ New")
685
+ btn_new.clicked.connect(self._new_prompt)
686
+ btn_layout.addWidget(btn_new)
687
+
688
+ btn_folder = QPushButton("📁 New Folder")
689
+ btn_folder.clicked.connect(self._new_folder)
690
+ btn_layout.addWidget(btn_folder)
691
+
692
+ btn_settings = QPushButton("⚙️ System Prompts")
693
+ btn_settings.clicked.connect(self._open_system_prompts_settings)
694
+ btn_settings.setToolTip("Configure mode-specific system prompts (Settings)")
695
+ btn_layout.addWidget(btn_settings)
696
+
697
+ btn_refresh = QPushButton("🔄 Refresh")
698
+ btn_refresh.clicked.connect(self._refresh_library)
699
+ btn_layout.addWidget(btn_refresh)
700
+
701
+ btn_collapse_all = QPushButton("▸ Collapse all")
702
+ btn_collapse_all.setToolTip("Collapse all folders in the Prompt Library tree")
703
+ btn_collapse_all.clicked.connect(self._collapse_prompt_library_tree)
704
+ btn_layout.addWidget(btn_collapse_all)
705
+
706
+ btn_expand_all = QPushButton("▾ Expand all")
707
+ btn_expand_all.setToolTip("Expand all folders in the Prompt Library tree")
708
+ btn_expand_all.clicked.connect(self._expand_prompt_library_tree)
709
+ btn_layout.addWidget(btn_expand_all)
710
+
711
+ btn_layout.addStretch()
712
+
713
+ return container
714
+
715
+ def _create_ai_assistant_tab(self) -> QWidget:
716
+ """Create the AI Assistant sub-tab"""
717
+ tab = QWidget()
718
+ layout = QVBoxLayout(tab)
719
+ layout.setContentsMargins(5, 5, 5, 5)
720
+ layout.setSpacing(5)
721
+
722
+ # Quick Action Button at top
723
+ # Note: && is needed to display a single & (Qt uses & for keyboard shortcuts)
724
+ action_btn = QPushButton("🔍 Analyze Project && Generate Prompts")
725
+ action_btn.setStyleSheet("""
726
+ QPushButton {
727
+ background-color: #1976D2;
728
+ color: white;
729
+ font-size: 11pt;
730
+ font-weight: bold;
731
+ padding: 10px;
732
+ border-radius: 5px;
733
+ }
734
+ QPushButton:hover {
735
+ background-color: #1565C0;
736
+ }
737
+ QPushButton:pressed {
738
+ background-color: #0D47A1;
739
+ }
740
+ """)
741
+ action_btn.clicked.connect(self._analyze_and_generate)
742
+ layout.addWidget(action_btn, 0)
743
+
744
+ # Main content: Horizontal splitter (context sidebar | chat area)
745
+ main_splitter = QSplitter(Qt.Orientation.Horizontal)
746
+ main_splitter.setHandleWidth(3)
747
+
748
+ # Left: Context Sidebar
749
+ context_panel = self._create_context_sidebar()
750
+ context_panel.setMinimumWidth(200)
751
+ context_panel.setMaximumWidth(350)
752
+ main_splitter.addWidget(context_panel)
753
+
754
+ # Right: Chat Interface
755
+ chat_panel = self._create_chat_interface()
756
+ main_splitter.addWidget(chat_panel)
757
+
758
+ # Set splitter proportions (25% context, 75% chat)
759
+ main_splitter.setSizes([250, 750])
760
+ main_splitter.setStretchFactor(0, 0) # Context sidebar fixed-ish
761
+ main_splitter.setStretchFactor(1, 1) # Chat area expands
762
+
763
+ layout.addWidget(main_splitter, 1)
764
+
765
+ return tab
766
+
767
+ def _create_context_sidebar(self) -> QWidget:
768
+ """Create context sidebar showing available resources"""
769
+ panel = QWidget()
770
+ layout = QVBoxLayout(panel)
771
+ layout.setContentsMargins(5, 5, 5, 5)
772
+ layout.setSpacing(5)
773
+
774
+ # Title
775
+ title = QLabel("📋 Available Context")
776
+ title.setStyleSheet("font-weight: bold; font-size: 10pt; color: #1976D2;")
777
+ layout.addWidget(title)
778
+
779
+ # Scroll area for context items
780
+ scroll = QScrollArea()
781
+ scroll.setWidgetResizable(True)
782
+ scroll.setStyleSheet("QScrollArea { border: none; }")
783
+
784
+ content = QWidget()
785
+ content_layout = QVBoxLayout(content)
786
+ content_layout.setContentsMargins(0, 0, 0, 0)
787
+ content_layout.setSpacing(10)
788
+
789
+ # Current Project Document
790
+ self.context_current_doc = self._create_context_section(
791
+ "📄 Current Document",
792
+ "No document loaded"
793
+ )
794
+ content_layout.addWidget(self.context_current_doc)
795
+
796
+ # Attached Files (expandable section)
797
+ self.context_attached_files_frame = self._create_attached_files_section()
798
+ content_layout.addWidget(self.context_attached_files_frame)
799
+
800
+ # Prompts from Library
801
+ prompt_count = len(self.library.prompts)
802
+ self.context_prompts = self._create_context_section(
803
+ f"💡 Prompt Library ({prompt_count})",
804
+ f"{prompt_count} prompts available\nClick to select specific prompts"
805
+ )
806
+ self.context_prompts.setCursor(Qt.CursorShape.PointingHandCursor)
807
+ content_layout.addWidget(self.context_prompts)
808
+
809
+ # Translation Memories
810
+ self.context_tms = self._create_context_section(
811
+ "💾 Translation Memories",
812
+ "Click to include TM data"
813
+ )
814
+ self.context_tms.setCursor(Qt.CursorShape.PointingHandCursor)
815
+ content_layout.addWidget(self.context_tms)
816
+
817
+ # Termbases
818
+ self.context_termbases = self._create_context_section(
819
+ "📚 Termbases",
820
+ "Click to include termbase data"
821
+ )
822
+ self.context_termbases.setCursor(Qt.CursorShape.PointingHandCursor)
823
+ content_layout.addWidget(self.context_termbases)
824
+
825
+ content_layout.addStretch()
826
+
827
+ scroll.setWidget(content)
828
+ layout.addWidget(scroll, 1)
829
+
830
+ return panel
831
+
832
+ def _create_context_section(self, title: str, description: str) -> QFrame:
833
+ """Create a context section widget"""
834
+ frame = QFrame()
835
+ frame.setStyleSheet("""
836
+ QFrame {
837
+ background-color: #F5F5F5;
838
+ border: 1px solid #E0E0E0;
839
+ border-radius: 5px;
840
+ padding: 8px;
841
+ }
842
+ QFrame:hover {
843
+ background-color: #EEEEEE;
844
+ border: 1px solid #BDBDBD;
845
+ }
846
+ """)
847
+
848
+ layout = QVBoxLayout(frame)
849
+ layout.setContentsMargins(5, 5, 5, 5)
850
+ layout.setSpacing(3)
851
+
852
+ title_label = QLabel(title)
853
+ title_label.setStyleSheet("font-weight: bold; font-size: 9pt;")
854
+ layout.addWidget(title_label)
855
+
856
+ desc_label = QLabel(description)
857
+ desc_label.setStyleSheet("color: #666; font-size: 8pt;")
858
+ desc_label.setWordWrap(True)
859
+ layout.addWidget(desc_label)
860
+
861
+ return frame
862
+
863
+ def _create_attached_files_section(self) -> QFrame:
864
+ """Create expandable attached files section with view/remove buttons"""
865
+ frame = QFrame()
866
+ frame.setStyleSheet("""
867
+ QFrame {
868
+ background-color: #F5F5F5;
869
+ border: 1px solid #E0E0E0;
870
+ border-radius: 5px;
871
+ padding: 8px;
872
+ }
873
+ """)
874
+
875
+ layout = QVBoxLayout(frame)
876
+ layout.setContentsMargins(5, 5, 5, 5)
877
+ layout.setSpacing(5)
878
+
879
+ # Header with expand/collapse button
880
+ header_layout = QHBoxLayout()
881
+ header_layout.setSpacing(5)
882
+
883
+ self.attached_files_expand_btn = QPushButton("▼")
884
+ self.attached_files_expand_btn.setFixedSize(20, 20)
885
+ self.attached_files_expand_btn.setStyleSheet("""
886
+ QPushButton {
887
+ background-color: transparent;
888
+ border: none;
889
+ font-size: 10pt;
890
+ }
891
+ QPushButton:hover {
892
+ background-color: #E0E0E0;
893
+ }
894
+ """)
895
+ self.attached_files_expand_btn.clicked.connect(self._toggle_attached_files)
896
+ header_layout.addWidget(self.attached_files_expand_btn)
897
+
898
+ self.attached_files_title = QLabel("📎 Attached Files (0)")
899
+ self.attached_files_title.setStyleSheet("font-weight: bold; font-size: 9pt;")
900
+ header_layout.addWidget(self.attached_files_title, 1)
901
+
902
+ # Attach button
903
+ attach_btn = QPushButton("+")
904
+ attach_btn.setFixedSize(20, 20)
905
+ attach_btn.setToolTip("Attach file")
906
+ attach_btn.setStyleSheet("""
907
+ QPushButton {
908
+ background-color: #1976D2;
909
+ color: white;
910
+ border: none;
911
+ border-radius: 3px;
912
+ font-size: 12pt;
913
+ font-weight: bold;
914
+ }
915
+ QPushButton:hover {
916
+ background-color: #1565C0;
917
+ }
918
+ """)
919
+ attach_btn.clicked.connect(self._attach_file)
920
+ header_layout.addWidget(attach_btn)
921
+
922
+ layout.addLayout(header_layout)
923
+
924
+ # File list container (collapsible)
925
+ self.attached_files_container = QWidget()
926
+ self.attached_files_list_layout = QVBoxLayout(self.attached_files_container)
927
+ self.attached_files_list_layout.setContentsMargins(5, 5, 5, 5)
928
+ self.attached_files_list_layout.setSpacing(5)
929
+
930
+ # Initially empty
931
+ no_files_label = QLabel("No files attached")
932
+ no_files_label.setStyleSheet("color: #999; font-size: 8pt; font-style: italic;")
933
+ self.attached_files_list_layout.addWidget(no_files_label)
934
+
935
+ layout.addWidget(self.attached_files_container)
936
+
937
+ # Initially expanded
938
+ self.attached_files_expanded = True
939
+
940
+ return frame
941
+
942
+ def _toggle_attached_files(self):
943
+ """Toggle attached files section expansion"""
944
+ self.attached_files_expanded = not self.attached_files_expanded
945
+ self.attached_files_container.setVisible(self.attached_files_expanded)
946
+ self.attached_files_expand_btn.setText("▼" if self.attached_files_expanded else "▶")
947
+
948
+ def _refresh_attached_files_list(self):
949
+ """Refresh the attached files list display"""
950
+ # Clear current list
951
+ while self.attached_files_list_layout.count():
952
+ item = self.attached_files_list_layout.takeAt(0)
953
+ if item.widget():
954
+ item.widget().deleteLater()
955
+
956
+ # Update title count
957
+ count = len(self.attached_files)
958
+ self.attached_files_title.setText(f"📎 Attached Files ({count})")
959
+
960
+ # If no files, show placeholder
961
+ if count == 0:
962
+ no_files_label = QLabel("No files attached")
963
+ no_files_label.setStyleSheet("color: #999; font-size: 8pt; font-style: italic;")
964
+ self.attached_files_list_layout.addWidget(no_files_label)
965
+ return
966
+
967
+ # Add each file
968
+ for file_data in self.attached_files:
969
+ file_widget = self._create_file_item_widget(file_data)
970
+ self.attached_files_list_layout.addWidget(file_widget)
971
+
972
+ def _create_file_item_widget(self, file_data: dict) -> QFrame:
973
+ """Create widget for a single attached file"""
974
+ item_frame = QFrame()
975
+ item_frame.setStyleSheet("""
976
+ QFrame {
977
+ background-color: white;
978
+ border: 1px solid #E0E0E0;
979
+ border-radius: 3px;
980
+ padding: 4px;
981
+ }
982
+ QFrame:hover {
983
+ border: 1px solid #1976D2;
984
+ }
985
+ """)
986
+
987
+ layout = QVBoxLayout(item_frame)
988
+ layout.setContentsMargins(5, 5, 5, 5)
989
+ layout.setSpacing(3)
990
+
991
+ # Filename
992
+ name_label = QLabel(file_data.get('name', 'Unknown'))
993
+ name_label.setStyleSheet("font-weight: bold; font-size: 8pt;")
994
+ name_label.setWordWrap(True)
995
+ layout.addWidget(name_label)
996
+
997
+ # Size and type
998
+ size = file_data.get('size', 0)
999
+ size_kb = size / 1024 if size > 0 else 0
1000
+ file_type = file_data.get('type', '')
1001
+ info_label = QLabel(f"{file_type} • {size_kb:.1f} KB")
1002
+ info_label.setStyleSheet("color: #666; font-size: 7pt;")
1003
+ layout.addWidget(info_label)
1004
+
1005
+ # Buttons
1006
+ btn_layout = QHBoxLayout()
1007
+ btn_layout.setSpacing(3)
1008
+
1009
+ view_btn = QPushButton("👁 View")
1010
+ view_btn.setStyleSheet("""
1011
+ QPushButton {
1012
+ background-color: #1976D2;
1013
+ color: white;
1014
+ border: none;
1015
+ border-radius: 2px;
1016
+ padding: 2px 6px;
1017
+ font-size: 7pt;
1018
+ }
1019
+ QPushButton:hover {
1020
+ background-color: #1565C0;
1021
+ }
1022
+ """)
1023
+ view_btn.clicked.connect(lambda: self._view_file(file_data))
1024
+ btn_layout.addWidget(view_btn)
1025
+
1026
+ remove_btn = QPushButton("❌")
1027
+ remove_btn.setStyleSheet("""
1028
+ QPushButton {
1029
+ background-color: #d32f2f;
1030
+ color: white;
1031
+ border: none;
1032
+ border-radius: 2px;
1033
+ padding: 2px 6px;
1034
+ font-size: 7pt;
1035
+ }
1036
+ QPushButton:hover {
1037
+ background-color: #b71c1c;
1038
+ }
1039
+ """)
1040
+ remove_btn.clicked.connect(lambda: self._remove_file(file_data))
1041
+ btn_layout.addWidget(remove_btn)
1042
+
1043
+ btn_layout.addStretch()
1044
+
1045
+ layout.addLayout(btn_layout)
1046
+
1047
+ return item_frame
1048
+
1049
+ def _view_file(self, file_data: dict):
1050
+ """View an attached file"""
1051
+ try:
1052
+ file_id = file_data.get('file_id')
1053
+ if file_id:
1054
+ # Load from AttachmentManager
1055
+ full_data = self.attachment_manager.get_file(file_id)
1056
+ if full_data:
1057
+ dialog = FileViewerDialog(full_data, self.main_widget)
1058
+ dialog.exec()
1059
+ else:
1060
+ QMessageBox.warning(
1061
+ self.main_widget,
1062
+ "File Not Found",
1063
+ "File data not found in storage."
1064
+ )
1065
+ else:
1066
+ # Fallback: use in-memory data
1067
+ dialog = FileViewerDialog(file_data, self.main_widget)
1068
+ dialog.exec()
1069
+ except Exception as e:
1070
+ QMessageBox.warning(
1071
+ self.main_widget,
1072
+ "View Error",
1073
+ f"Failed to view file:\n{e}"
1074
+ )
1075
+
1076
+ def _remove_file(self, file_data: dict):
1077
+ """Remove an attached file"""
1078
+ try:
1079
+ filename = file_data.get('name', 'Unknown')
1080
+
1081
+ # Confirm removal
1082
+ dialog = FileRemoveConfirmDialog(filename, self.main_widget)
1083
+ if dialog.exec() != QDialog.DialogCode.Accepted:
1084
+ return
1085
+
1086
+ file_id = file_data.get('file_id')
1087
+
1088
+ # Remove from AttachmentManager
1089
+ if file_id:
1090
+ self.attachment_manager.remove_file(file_id)
1091
+
1092
+ # Remove from in-memory list
1093
+ if file_data in self.attached_files:
1094
+ self.attached_files.remove(file_data)
1095
+
1096
+ # Update UI
1097
+ self._refresh_attached_files_list()
1098
+ self._save_conversation_history()
1099
+
1100
+ # Add system message
1101
+ self._add_chat_message(
1102
+ "system",
1103
+ f"🗑️ Removed file: **{filename}**"
1104
+ )
1105
+
1106
+ except Exception as e:
1107
+ QMessageBox.warning(
1108
+ self.main_widget,
1109
+ "Remove Error",
1110
+ f"Failed to remove file:\n{e}"
1111
+ )
1112
+
1113
+ def _create_chat_interface(self) -> QWidget:
1114
+ """Create chat interface with messages and input"""
1115
+ panel = QWidget()
1116
+ layout = QVBoxLayout(panel)
1117
+ layout.setContentsMargins(5, 5, 5, 5)
1118
+ layout.setSpacing(5)
1119
+
1120
+ # Chat messages area (using QListWidget with custom delegate)
1121
+ self.chat_display = QListWidget()
1122
+ self.chat_display.setItemDelegate(ChatMessageDelegate())
1123
+ self.chat_display.setStyleSheet("""
1124
+ QListWidget {
1125
+ background-color: #FFFFFF;
1126
+ border: 1px solid #E8E8EA;
1127
+ border-radius: 8px;
1128
+ font-size: 10pt;
1129
+ font-family: 'Segoe UI', system-ui, sans-serif;
1130
+ }
1131
+ QListWidget::item {
1132
+ border: none;
1133
+ background: transparent;
1134
+ }
1135
+ QListWidget::item:selected {
1136
+ background: transparent;
1137
+ }
1138
+ QListWidget::item:hover {
1139
+ background: transparent;
1140
+ }
1141
+ """)
1142
+ self.chat_display.setSelectionMode(QListWidget.SelectionMode.NoSelection)
1143
+ self.chat_display.setFocusPolicy(Qt.FocusPolicy.NoFocus)
1144
+ self.chat_display.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
1145
+ self.chat_display.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
1146
+ self.chat_display.setSpacing(0)
1147
+ # Enable context menu for copying messages
1148
+ self.chat_display.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1149
+ self.chat_display.customContextMenuRequested.connect(self._show_chat_context_menu)
1150
+ layout.addWidget(self.chat_display, 1)
1151
+
1152
+ # Top toolbar with Clear button
1153
+ toolbar_frame = QFrame()
1154
+ toolbar_layout = QHBoxLayout(toolbar_frame)
1155
+ toolbar_layout.setContentsMargins(0, 0, 0, 5)
1156
+ toolbar_layout.setSpacing(5)
1157
+
1158
+ clear_btn = QPushButton("🗑️ Clear Chat")
1159
+ clear_btn.setStyleSheet("""
1160
+ QPushButton {
1161
+ background-color: #757575;
1162
+ color: white;
1163
+ padding: 6px 12px;
1164
+ border-radius: 4px;
1165
+ border: none;
1166
+ font-size: 9pt;
1167
+ }
1168
+ QPushButton:hover {
1169
+ background-color: #616161;
1170
+ }
1171
+ QPushButton:pressed {
1172
+ background-color: #424242;
1173
+ }
1174
+ """)
1175
+ clear_btn.clicked.connect(self._clear_chat)
1176
+ toolbar_layout.addWidget(clear_btn)
1177
+ toolbar_layout.addStretch()
1178
+
1179
+ layout.addWidget(toolbar_frame, 0)
1180
+
1181
+ # Input area
1182
+ input_frame = QFrame()
1183
+ input_frame.setStyleSheet("""
1184
+ QFrame {
1185
+ background-color: white;
1186
+ border: 1px solid #E0E0E0;
1187
+ border-radius: 5px;
1188
+ padding: 5px;
1189
+ }
1190
+ """)
1191
+ input_layout = QHBoxLayout(input_frame)
1192
+ input_layout.setContentsMargins(5, 5, 5, 5)
1193
+ input_layout.setSpacing(5)
1194
+
1195
+ self.chat_input = QPlainTextEdit()
1196
+ self.chat_input.setPlaceholderText("Type your message here... (Shift+Enter for new line)")
1197
+ self.chat_input.setMaximumHeight(80)
1198
+ self.chat_input.setStyleSheet("""
1199
+ QPlainTextEdit {
1200
+ border: none;
1201
+ font-size: 10pt;
1202
+ color: #1a1a1a;
1203
+ background-color: white;
1204
+ padding: 4px;
1205
+ }
1206
+ """)
1207
+ input_layout.addWidget(self.chat_input, 1)
1208
+
1209
+ send_btn = QPushButton("Send")
1210
+ send_btn.setStyleSheet("""
1211
+ QPushButton {
1212
+ background-color: #1976D2;
1213
+ color: white;
1214
+ font-weight: bold;
1215
+ padding: 8px 20px;
1216
+ border-radius: 5px;
1217
+ border: none;
1218
+ }
1219
+ QPushButton:hover {
1220
+ background-color: #1565C0;
1221
+ }
1222
+ QPushButton:pressed {
1223
+ background-color: #0D47A1;
1224
+ }
1225
+ """)
1226
+ send_btn.clicked.connect(self._send_chat_message)
1227
+ input_layout.addWidget(send_btn)
1228
+
1229
+ layout.addWidget(input_frame, 0)
1230
+
1231
+ return panel
1232
+
1233
+ def _create_header(self) -> QWidget:
1234
+ """Create header - matches TMX Editor style exactly"""
1235
+ header_container = QWidget()
1236
+ layout = QVBoxLayout(header_container)
1237
+ layout.setContentsMargins(0, 0, 0, 0)
1238
+ layout.setSpacing(5) # Reduced from 10 to 5 for tighter spacing
1239
+
1240
+ # Header (matches TMX Editor style)
1241
+ title = QLabel("📚 Prompt Library")
1242
+ title.setStyleSheet("font-size: 16pt; font-weight: bold; color: #1976D2;")
1243
+ layout.addWidget(title, 0) # 0 = no stretch, stays compact
1244
+
1245
+ # Description box (matches TMX Editor style)
1246
+ desc_text = QLabel(
1247
+ f"Custom instructions for AI translation.\n"
1248
+ f"• Mode: {self._get_mode_display_name()}"
1249
+ )
1250
+ desc_text.setWordWrap(True)
1251
+ desc_text.setStyleSheet("color: #666; padding: 5px; background-color: #E3F2FD; border-radius: 3px;")
1252
+ layout.addWidget(desc_text, 0) # 0 = no stretch, stays compact
1253
+ self.mode_label = desc_text # Store reference for updates
1254
+
1255
+ # Toolbar buttons row
1256
+ toolbar = QWidget()
1257
+ toolbar_layout = QHBoxLayout(toolbar)
1258
+ toolbar_layout.setContentsMargins(0, 5, 0, 0)
1259
+ toolbar_layout.setSpacing(5)
1260
+
1261
+ btn_new = QPushButton("+ New")
1262
+ btn_new.clicked.connect(self._new_prompt)
1263
+ toolbar_layout.addWidget(btn_new)
1264
+
1265
+ btn_folder = QPushButton("📁 New Folder")
1266
+ btn_folder.clicked.connect(self._new_folder)
1267
+ toolbar_layout.addWidget(btn_folder)
1268
+
1269
+ btn_settings = QPushButton("⚙️ System Prompts")
1270
+ btn_settings.clicked.connect(self._open_system_prompts_settings)
1271
+ btn_settings.setToolTip("Configure mode-specific system prompts (Settings)")
1272
+ toolbar_layout.addWidget(btn_settings)
1273
+
1274
+ btn_refresh = QPushButton("🔄 Refresh")
1275
+ btn_refresh.clicked.connect(self._refresh_library)
1276
+ toolbar_layout.addWidget(btn_refresh)
1277
+
1278
+ btn_collapse_all = QPushButton("▸ Collapse all")
1279
+ btn_collapse_all.setToolTip("Collapse all folders in the Prompt Library tree")
1280
+ btn_collapse_all.clicked.connect(self._collapse_prompt_library_tree)
1281
+ toolbar_layout.addWidget(btn_collapse_all)
1282
+
1283
+ btn_expand_all = QPushButton("▾ Expand all")
1284
+ btn_expand_all.setToolTip("Expand all folders in the Prompt Library tree")
1285
+ btn_expand_all.clicked.connect(self._expand_prompt_library_tree)
1286
+ toolbar_layout.addWidget(btn_expand_all)
1287
+
1288
+ toolbar_layout.addStretch()
1289
+
1290
+ layout.addWidget(toolbar, 0)
1291
+
1292
+ return header_container
1293
+
1294
+ def _create_library_tree_panel(self) -> QWidget:
1295
+ """Create left panel with folder tree"""
1296
+ panel = QWidget()
1297
+ layout = QVBoxLayout(panel)
1298
+ layout.setContentsMargins(0, 0, 0, 0)
1299
+
1300
+ # Tree widget
1301
+ self.tree_widget = PromptLibraryTreeWidget(self)
1302
+ self.tree_widget.setHeaderLabels(["Prompt Library"])
1303
+ self.tree_widget.setAlternatingRowColors(True)
1304
+ self.tree_widget.itemClicked.connect(self._on_tree_item_clicked)
1305
+ self.tree_widget.itemDoubleClicked.connect(self._on_tree_item_double_clicked)
1306
+ self.tree_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1307
+ self.tree_widget.customContextMenuRequested.connect(self._show_tree_context_menu)
1308
+
1309
+ layout.addWidget(self.tree_widget)
1310
+
1311
+ return panel
1312
+
1313
+ def _create_active_config_panel(self) -> QGroupBox:
1314
+ """Create active prompt configuration panel"""
1315
+ group = QGroupBox("Active Configuration")
1316
+ layout = QVBoxLayout()
1317
+
1318
+ # Mode info (read-only, auto-selected)
1319
+ mode_frame = QFrame()
1320
+ mode_frame.setFrameStyle(QFrame.Shape.StyledPanel)
1321
+ mode_layout = QHBoxLayout(mode_frame)
1322
+ mode_layout.setContentsMargins(10, 5, 10, 5)
1323
+
1324
+ mode_label = QLabel(f"🔧 Current Mode: {self._get_mode_display_name()}")
1325
+ mode_label.setFont(QFont("Segoe UI", 9))
1326
+ mode_layout.addWidget(mode_label)
1327
+
1328
+ btn_view_template = QPushButton("View System Prompt")
1329
+ btn_view_template.clicked.connect(self._view_current_system_template)
1330
+ btn_view_template.setMaximumWidth(150)
1331
+ mode_layout.addWidget(btn_view_template)
1332
+
1333
+ layout.addWidget(mode_frame)
1334
+
1335
+ # Primary Prompt
1336
+ primary_layout = QHBoxLayout()
1337
+ primary_label = QLabel("Primary Prompt ⭐:")
1338
+ primary_label.setFont(QFont("Segoe UI", 9, QFont.Weight.Bold))
1339
+ primary_layout.addWidget(primary_label)
1340
+
1341
+ self.primary_prompt_label = QLabel("[None selected]")
1342
+ self.primary_prompt_label.setStyleSheet("color: #999;")
1343
+ primary_layout.addWidget(self.primary_prompt_label, 1)
1344
+
1345
+ btn_load_external = QPushButton("Load External...")
1346
+ btn_load_external.clicked.connect(self._load_external_primary_prompt)
1347
+ btn_load_external.setToolTip("Load a prompt file from anywhere on your computer")
1348
+ btn_load_external.setMaximumWidth(100)
1349
+ primary_layout.addWidget(btn_load_external)
1350
+
1351
+ btn_clear_primary = QPushButton("Clear")
1352
+ btn_clear_primary.clicked.connect(self._clear_primary_prompt)
1353
+ btn_clear_primary.setMaximumWidth(60)
1354
+ primary_layout.addWidget(btn_clear_primary)
1355
+
1356
+ layout.addLayout(primary_layout)
1357
+
1358
+ # Attached Prompts
1359
+ attached_label = QLabel("Attached Prompts 📎:")
1360
+ attached_label.setFont(QFont("Segoe UI", 9, QFont.Weight.Bold))
1361
+ layout.addWidget(attached_label)
1362
+
1363
+ # Scrollable list of attached prompts
1364
+ self.attached_list_widget = QTreeWidget()
1365
+ self.attached_list_widget.setHeaderLabels(["Name", ""])
1366
+ self.attached_list_widget.setMaximumHeight(100)
1367
+ self.attached_list_widget.setRootIsDecorated(False)
1368
+ self.attached_list_widget.setColumnWidth(0, 200)
1369
+ layout.addWidget(self.attached_list_widget)
1370
+
1371
+ # Image Context (visual context for AI)
1372
+ image_layout = QHBoxLayout()
1373
+ image_label = QLabel("Image Context 🖼️:")
1374
+ image_label.setFont(QFont("Segoe UI", 9, QFont.Weight.Bold))
1375
+ image_layout.addWidget(image_label)
1376
+
1377
+ self.image_context_label = QLabel("[None loaded]")
1378
+ self.image_context_label.setStyleSheet("color: #999;")
1379
+ self.image_context_label.setToolTip(
1380
+ "Images loaded via Project Resources → Image Context tab\n"
1381
+ "are sent as binary data alongside your prompt when\n"
1382
+ "figure references (Fig. 1, Figure 2A, etc.) are detected."
1383
+ )
1384
+ image_layout.addWidget(self.image_context_label, 1)
1385
+
1386
+ layout.addLayout(image_layout)
1387
+
1388
+ # Buttons
1389
+ btn_layout = QHBoxLayout()
1390
+
1391
+ btn_preview = QPushButton("Preview Combined")
1392
+ btn_preview.setToolTip("Preview the complete assembled prompt that will be sent to the AI\n(System Prompt + Project Instructions + Custom Prompts + your text)")
1393
+ btn_preview.clicked.connect(self._preview_combined_prompt)
1394
+ btn_layout.addWidget(btn_preview)
1395
+
1396
+ btn_clear_all = QPushButton("Clear All Attachments")
1397
+ btn_clear_all.clicked.connect(self._clear_all_attachments)
1398
+ btn_layout.addWidget(btn_clear_all)
1399
+
1400
+ btn_layout.addStretch()
1401
+
1402
+ layout.addLayout(btn_layout)
1403
+
1404
+ group.setLayout(layout)
1405
+ group.setMaximumHeight(280)
1406
+
1407
+ return group
1408
+
1409
+ def _create_editor_panel(self) -> QGroupBox:
1410
+ """Create prompt editor panel"""
1411
+ group = QGroupBox("Prompt Editor")
1412
+ layout = QVBoxLayout()
1413
+
1414
+ # Toolbar
1415
+ toolbar = QHBoxLayout()
1416
+
1417
+ self.editor_name_label = QLabel("Select a prompt to edit")
1418
+ self.editor_name_label.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold))
1419
+ toolbar.addWidget(self.editor_name_label)
1420
+
1421
+ toolbar.addStretch()
1422
+
1423
+ self.btn_save_prompt = QPushButton("💾 Save")
1424
+ self.btn_save_prompt.clicked.connect(self._save_current_prompt)
1425
+ self.btn_save_prompt.setEnabled(False)
1426
+ toolbar.addWidget(self.btn_save_prompt)
1427
+
1428
+ layout.addLayout(toolbar)
1429
+
1430
+ # External file path display (hidden by default)
1431
+ self.external_path_frame = QFrame()
1432
+ external_path_layout = QHBoxLayout(self.external_path_frame)
1433
+ external_path_layout.setContentsMargins(0, 0, 0, 4)
1434
+ external_path_layout.addWidget(QLabel("📂 Location:"))
1435
+ self.external_path_label = QLabel()
1436
+ self.external_path_label.setStyleSheet("color: #0066cc; text-decoration: underline;")
1437
+ self.external_path_label.setCursor(Qt.CursorShape.PointingHandCursor)
1438
+ self.external_path_label.setToolTip("Click to open containing folder")
1439
+ self.external_path_label.mousePressEvent = self._open_external_prompt_folder
1440
+ external_path_layout.addWidget(self.external_path_label, 1)
1441
+ self.btn_open_folder = QPushButton("📁 Open Folder")
1442
+ self.btn_open_folder.setMaximumWidth(100)
1443
+ self.btn_open_folder.clicked.connect(lambda: self._open_external_prompt_folder(None))
1444
+ external_path_layout.addWidget(self.btn_open_folder)
1445
+ self.external_path_frame.setVisible(False)
1446
+ layout.addWidget(self.external_path_frame)
1447
+
1448
+ # Metadata fields
1449
+ metadata_layout = QHBoxLayout()
1450
+
1451
+ # Name
1452
+ metadata_layout.addWidget(QLabel("Name:"))
1453
+ self.editor_name_input = QLineEdit()
1454
+ self.editor_name_input.setPlaceholderText("Prompt name")
1455
+ metadata_layout.addWidget(self.editor_name_input, 2)
1456
+
1457
+ # Description
1458
+ metadata_layout.addWidget(QLabel("Description:"))
1459
+ self.editor_desc_input = QLineEdit()
1460
+ self.editor_desc_input.setPlaceholderText("Brief description")
1461
+ metadata_layout.addWidget(self.editor_desc_input, 3)
1462
+
1463
+ layout.addLayout(metadata_layout)
1464
+
1465
+ # QuickMenu fields
1466
+ quickmenu_layout = QHBoxLayout()
1467
+
1468
+ quickmenu_layout.addWidget(QLabel("QuickMenu label:"))
1469
+ self.editor_quickmenu_label_input = QLineEdit()
1470
+ self.editor_quickmenu_label_input.setPlaceholderText("Label shown in QuickMenu")
1471
+ quickmenu_layout.addWidget(self.editor_quickmenu_label_input, 2)
1472
+
1473
+ self.editor_quickmenu_in_grid_cb = QCheckBox("Show in Grid right-click QuickMenu")
1474
+ quickmenu_layout.addWidget(self.editor_quickmenu_in_grid_cb, 2)
1475
+
1476
+ self.editor_quickmenu_in_quickmenu_cb = QCheckBox("Show in QuickMenu")
1477
+ quickmenu_layout.addWidget(self.editor_quickmenu_in_quickmenu_cb, 1)
1478
+
1479
+ layout.addLayout(quickmenu_layout)
1480
+
1481
+ # Content editor
1482
+ self.editor_content = QPlainTextEdit()
1483
+ self.editor_content.setPlaceholderText("Enter prompt content here...")
1484
+ self.editor_content.setFont(QFont("Consolas", 10))
1485
+ layout.addWidget(self.editor_content)
1486
+
1487
+ group.setLayout(layout)
1488
+
1489
+ return group
1490
+
1491
+ def _get_mode_display_name(self) -> str:
1492
+ """Get display name for current mode"""
1493
+ mode_names = {
1494
+ "single": "Single Segment",
1495
+ "batch_docx": "Batch DOCX",
1496
+ "batch_bilingual": "Batch Bilingual"
1497
+ }
1498
+ return mode_names.get(self.current_mode, "Single Segment")
1499
+
1500
+ def _refresh_tree(self):
1501
+ """Refresh the library tree view"""
1502
+ tree_state = self._capture_prompt_tree_state()
1503
+ self.tree_widget.clear()
1504
+
1505
+ # Debug: Show what we have
1506
+ self.log_message(f"🔍 DEBUG: Refreshing tree with {len(self.library.prompts)} prompts")
1507
+ self.log_message(f"🔍 DEBUG: Library dir: {self.unified_library_dir}")
1508
+ self.log_message(f"🔍 DEBUG: Library dir exists: {self.unified_library_dir.exists()}")
1509
+
1510
+ # Favorites section
1511
+ favorites_root = QTreeWidgetItem(["⭐ Favorites"])
1512
+ # Special node: not draggable/droppable
1513
+ favorites_root.setData(0, Qt.ItemDataRole.UserRole, {'type': 'special', 'kind': 'favorites'})
1514
+ favorites_root.setExpanded(False)
1515
+ font = favorites_root.font(0)
1516
+ font.setBold(True)
1517
+ favorites_root.setFont(0, font)
1518
+ self.tree_widget.addTopLevelItem(favorites_root)
1519
+
1520
+ favorites = self.library.get_favorites()
1521
+ self.log_message(f"🔍 DEBUG: Favorites count: {len(favorites)}")
1522
+ for path, name in favorites:
1523
+ item = QTreeWidgetItem([name])
1524
+ item.setData(0, Qt.ItemDataRole.UserRole, {'type': 'prompt', 'path': path})
1525
+ # Favorites are shortcuts, but allow dragging to move the actual prompt file.
1526
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsDragEnabled)
1527
+ favorites_root.addChild(item)
1528
+
1529
+ # QuickMenu section (legacy kind name: quick_run)
1530
+ quick_run_root = QTreeWidgetItem(["⚡ QuickMenu"])
1531
+ # Special node: not draggable/droppable
1532
+ quick_run_root.setData(0, Qt.ItemDataRole.UserRole, {'type': 'special', 'kind': 'quick_run'})
1533
+ quick_run_root.setExpanded(False)
1534
+ font = quick_run_root.font(0)
1535
+ font.setBold(True)
1536
+ quick_run_root.setFont(0, font)
1537
+ self.tree_widget.addTopLevelItem(quick_run_root)
1538
+
1539
+ quickmenu_items = self.library.get_quickmenu_prompts() if hasattr(self.library, 'get_quickmenu_prompts') else self.library.get_quick_run_prompts()
1540
+ self.log_message(f"🔍 DEBUG: QuickMenu count: {len(quickmenu_items)}")
1541
+ for path, label in quickmenu_items:
1542
+ item = QTreeWidgetItem([label])
1543
+ item.setData(0, Qt.ItemDataRole.UserRole, {'type': 'prompt', 'path': path})
1544
+ # Quick Run entries are shortcuts, but allow dragging to move the actual prompt file.
1545
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsDragEnabled)
1546
+ quick_run_root.addChild(item)
1547
+
1548
+ # Library folders
1549
+ self.log_message(f"🔍 DEBUG: Building tree from {self.unified_library_dir}")
1550
+ self._build_tree_recursive(None, self.unified_library_dir, "")
1551
+
1552
+ # Debug: Check what's in the tree
1553
+ self.log_message(f"🔍 DEBUG: Tree has {self.tree_widget.topLevelItemCount()} top-level items")
1554
+ for i in range(self.tree_widget.topLevelItemCount()):
1555
+ item = self.tree_widget.topLevelItem(i)
1556
+ text = item.text(0)
1557
+ child_count = item.childCount()
1558
+ self.log_message(f"🔍 DEBUG: Top-level item {i}: '{text}' with {child_count} children")
1559
+
1560
+ # Preserve user's expansion state across refreshes.
1561
+ if tree_state is None and not getattr(self, "_prompt_tree_state_initialized", False):
1562
+ # First-ever refresh: default to collapsed ("popped in").
1563
+ self.tree_widget.collapseAll()
1564
+ self._prompt_tree_state_initialized = True
1565
+ else:
1566
+ self._restore_prompt_tree_state(tree_state)
1567
+
1568
+ self.log_message("🔍 DEBUG: Tree refresh complete")
1569
+
1570
+ def _capture_prompt_tree_state(self) -> Optional[Dict[str, object]]:
1571
+ """Capture expansion + selection state for the Prompt Library tree."""
1572
+ if not hasattr(self, 'tree_widget') or not self.tree_widget:
1573
+ return None
1574
+
1575
+ try:
1576
+ expanded_folders = set()
1577
+ expanded_special = {}
1578
+
1579
+ current = self.tree_widget.currentItem()
1580
+ current_sel = None
1581
+ if current is not None:
1582
+ data = current.data(0, Qt.ItemDataRole.UserRole)
1583
+ if data and data.get('type') in {'prompt', 'folder'}:
1584
+ current_sel = (data.get('type'), data.get('path'))
1585
+
1586
+ # Scroll position (best-effort)
1587
+ scroll_val = None
1588
+ try:
1589
+ sb = self.tree_widget.verticalScrollBar()
1590
+ scroll_val = sb.value() if sb is not None else None
1591
+ except Exception:
1592
+ scroll_val = None
1593
+
1594
+ def iter_items(parent: QTreeWidgetItem):
1595
+ for i in range(parent.childCount()):
1596
+ child = parent.child(i)
1597
+ yield child
1598
+ yield from iter_items(child)
1599
+
1600
+ root = self.tree_widget.invisibleRootItem()
1601
+ for item in iter_items(root):
1602
+ data = item.data(0, Qt.ItemDataRole.UserRole)
1603
+ if not data:
1604
+ continue
1605
+
1606
+ if data.get('type') == 'folder' and item.isExpanded():
1607
+ path = data.get('path')
1608
+ if path:
1609
+ expanded_folders.add(path)
1610
+
1611
+ if data.get('type') == 'special':
1612
+ kind = data.get('kind')
1613
+ if kind:
1614
+ expanded_special[kind] = item.isExpanded()
1615
+
1616
+ return {
1617
+ 'expanded_folders': expanded_folders,
1618
+ 'expanded_special': expanded_special,
1619
+ 'current_sel': current_sel,
1620
+ 'scroll_val': scroll_val,
1621
+ }
1622
+ except Exception:
1623
+ return None
1624
+
1625
+ def _restore_prompt_tree_state(self, state: Optional[Dict[str, object]]):
1626
+ """Restore expansion + selection state for the Prompt Library tree."""
1627
+ if not state or not hasattr(self, 'tree_widget') or not self.tree_widget:
1628
+ return
1629
+
1630
+ expanded_folders = state.get('expanded_folders', set()) or set()
1631
+ expanded_special = state.get('expanded_special', {}) or {}
1632
+ current_sel = state.get('current_sel')
1633
+ scroll_val = state.get('scroll_val')
1634
+
1635
+ def iter_items(parent: QTreeWidgetItem):
1636
+ for i in range(parent.childCount()):
1637
+ child = parent.child(i)
1638
+ yield child
1639
+ yield from iter_items(child)
1640
+
1641
+ # Restore expansions
1642
+ root = self.tree_widget.invisibleRootItem()
1643
+ for item in iter_items(root):
1644
+ data = item.data(0, Qt.ItemDataRole.UserRole)
1645
+ if not data:
1646
+ continue
1647
+
1648
+ if data.get('type') == 'folder':
1649
+ p = data.get('path')
1650
+ if p in expanded_folders:
1651
+ item.setExpanded(True)
1652
+
1653
+ if data.get('type') == 'special':
1654
+ kind = data.get('kind')
1655
+ if kind in expanded_special:
1656
+ item.setExpanded(bool(expanded_special[kind]))
1657
+
1658
+ # Restore selection (prefer the main library tree item over Favorites/Quick Run shortcuts)
1659
+ if current_sel and len(current_sel) == 2:
1660
+ sel_type, sel_path = current_sel
1661
+ if sel_type == 'prompt' and sel_path:
1662
+ self._select_and_reveal_prompt(sel_path, prefer_library_tree=True)
1663
+ elif sel_type == 'folder' and sel_path:
1664
+ self._select_and_reveal_folder(sel_path)
1665
+
1666
+ # Restore scroll position (best-effort)
1667
+ if scroll_val is not None:
1668
+ try:
1669
+ sb = self.tree_widget.verticalScrollBar()
1670
+ if sb is not None:
1671
+ sb.setValue(int(scroll_val))
1672
+ except Exception:
1673
+ pass
1674
+
1675
+ def _get_selected_folder_for_new_prompt(self) -> str:
1676
+ """Return folder path where a new prompt should be created based on current selection."""
1677
+ try:
1678
+ item = self.tree_widget.currentItem() if hasattr(self, 'tree_widget') else None
1679
+ if not item:
1680
+ return "Project Prompts"
1681
+
1682
+ data = item.data(0, Qt.ItemDataRole.UserRole)
1683
+ if not data:
1684
+ return "Project Prompts"
1685
+
1686
+ if data.get('type') == 'folder':
1687
+ return data.get('path', 'Project Prompts') or 'Project Prompts'
1688
+
1689
+ if data.get('type') == 'prompt':
1690
+ folder = str(Path(data.get('path', '')).parent)
1691
+ if folder and folder != '.':
1692
+ return folder
1693
+ return "Project Prompts"
1694
+
1695
+ except Exception:
1696
+ pass
1697
+
1698
+ return "Project Prompts"
1699
+
1700
+ def _select_and_reveal_prompt(self, relative_path: str, prefer_library_tree: bool = False):
1701
+ """Expand parent folders (if needed) and select the prompt item in the tree."""
1702
+ if not hasattr(self, 'tree_widget') or not self.tree_widget:
1703
+ return
1704
+
1705
+ def iter_items(parent: QTreeWidgetItem):
1706
+ for i in range(parent.childCount()):
1707
+ child = parent.child(i)
1708
+ yield child
1709
+ yield from iter_items(child)
1710
+
1711
+ def is_under_special(it: QTreeWidgetItem) -> bool:
1712
+ p = it.parent()
1713
+ while p is not None:
1714
+ d = p.data(0, Qt.ItemDataRole.UserRole)
1715
+ if d and d.get('type') == 'special':
1716
+ return True
1717
+ p = p.parent()
1718
+ return False
1719
+
1720
+ root = self.tree_widget.invisibleRootItem()
1721
+ best = None
1722
+ for item in iter_items(root):
1723
+ data = item.data(0, Qt.ItemDataRole.UserRole)
1724
+ if data and data.get('type') == 'prompt' and data.get('path') == relative_path:
1725
+ if prefer_library_tree and is_under_special(item):
1726
+ continue
1727
+ best = item
1728
+ break
1729
+
1730
+ if best is None and prefer_library_tree:
1731
+ # Fall back to any match
1732
+ for item in iter_items(root):
1733
+ data = item.data(0, Qt.ItemDataRole.UserRole)
1734
+ if data and data.get('type') == 'prompt' and data.get('path') == relative_path:
1735
+ best = item
1736
+ break
1737
+
1738
+ if best is not None:
1739
+ p = best.parent()
1740
+ while p is not None:
1741
+ p.setExpanded(True)
1742
+ p = p.parent()
1743
+ self.tree_widget.setCurrentItem(best)
1744
+ self.tree_widget.scrollToItem(best)
1745
+
1746
+ def _select_and_reveal_folder(self, folder_path: str):
1747
+ """Select a folder item in the tree and expand its ancestors."""
1748
+ if not hasattr(self, 'tree_widget') or not self.tree_widget:
1749
+ return
1750
+
1751
+ def iter_items(parent: QTreeWidgetItem):
1752
+ for i in range(parent.childCount()):
1753
+ child = parent.child(i)
1754
+ yield child
1755
+ yield from iter_items(child)
1756
+
1757
+ root = self.tree_widget.invisibleRootItem()
1758
+ for item in iter_items(root):
1759
+ data = item.data(0, Qt.ItemDataRole.UserRole)
1760
+ if data and data.get('type') == 'folder' and data.get('path') == folder_path:
1761
+ p = item.parent()
1762
+ while p is not None:
1763
+ p.setExpanded(True)
1764
+ p = p.parent()
1765
+ self.tree_widget.setCurrentItem(item)
1766
+ self.tree_widget.scrollToItem(item)
1767
+ return
1768
+
1769
+ def _make_unique_filename(self, folder: str, filename: str) -> str:
1770
+ """Generate a unique filename in a folder by appending (copy N) to the stem."""
1771
+ folder = folder or ""
1772
+ base = Path(filename).stem
1773
+ suffix = Path(filename).suffix or ".md"
1774
+
1775
+ def build(name_stem: str) -> str:
1776
+ return f"{folder}/{name_stem}{suffix}" if folder else f"{name_stem}{suffix}"
1777
+
1778
+ candidate_stem = f"{base} (copy)"
1779
+ candidate = build(candidate_stem)
1780
+ if candidate not in self.library.prompts:
1781
+ return candidate
1782
+
1783
+ n = 2
1784
+ while True:
1785
+ candidate_stem = f"{base} (copy {n})"
1786
+ candidate = build(candidate_stem)
1787
+ if candidate not in self.library.prompts:
1788
+ return candidate
1789
+ n += 1
1790
+
1791
+ def _make_unique_folder_path(self, dest_folder: str, folder_name: str) -> str:
1792
+ """Generate a unique folder path under dest_folder by appending (moved N)."""
1793
+ dest_folder = dest_folder or ""
1794
+
1795
+ def build(name: str) -> str:
1796
+ return f"{dest_folder}/{name}" if dest_folder else name
1797
+
1798
+ candidate = build(folder_name)
1799
+ if not (self.library.library_dir / candidate).exists():
1800
+ return candidate
1801
+
1802
+ n = 2
1803
+ while True:
1804
+ candidate = build(f"{folder_name} (moved {n})")
1805
+ if not (self.library.library_dir / candidate).exists():
1806
+ return candidate
1807
+ n += 1
1808
+
1809
+ def _move_folder_to_folder(self, src_folder: str, dest_folder: str) -> bool:
1810
+ """Move a folder (directory) to another folder (drag-and-drop backend)."""
1811
+ if not src_folder:
1812
+ return False
1813
+
1814
+ src_folder = src_folder.strip("/\\")
1815
+ dest_folder = (dest_folder or "").strip("/\\")
1816
+
1817
+ src_name = Path(src_folder).name
1818
+ new_folder = f"{dest_folder}/{src_name}" if dest_folder else src_name
1819
+
1820
+ # Prevent moving into itself/descendant
1821
+ if dest_folder and (dest_folder == src_folder or dest_folder.startswith(src_folder + "/")):
1822
+ QMessageBox.warning(self.main_widget, "Move not allowed", "You can't move a folder into itself.")
1823
+ return False
1824
+
1825
+ if new_folder == src_folder:
1826
+ return False
1827
+
1828
+ # Handle destination conflict
1829
+ if (self.library.library_dir / new_folder).exists():
1830
+ new_folder = self._make_unique_folder_path(dest_folder, src_name)
1831
+
1832
+ if not self.library.move_folder(src_folder, new_folder):
1833
+ QMessageBox.warning(self.main_widget, "Move failed", "Could not move the folder.")
1834
+ return False
1835
+
1836
+ # Reload prompts so keys/filepaths match new layout.
1837
+ self.library.load_all_prompts()
1838
+
1839
+ # Ensure active prompt content is still valid after path rewrite
1840
+ if self.library.active_primary_prompt_path and self.library.active_primary_prompt_path in self.library.prompts:
1841
+ self.library.active_primary_prompt = self.library.prompts[self.library.active_primary_prompt_path].get('content')
1842
+
1843
+ # Remove attachments that no longer exist (shouldn't happen, but safe)
1844
+ self.library.attached_prompt_paths = [p for p in self.library.attached_prompt_paths if p in self.library.prompts]
1845
+
1846
+ self._refresh_tree()
1847
+ return True
1848
+
1849
+ def _move_prompt_to_folder(self, src_path: str, dest_folder: str) -> bool:
1850
+ """Move a prompt file to another folder (drag-and-drop backend)."""
1851
+ if src_path not in self.library.prompts:
1852
+ return False
1853
+
1854
+ filename = Path(src_path).name
1855
+ dest_folder = dest_folder or ""
1856
+ new_path = f"{dest_folder}/{filename}" if dest_folder else filename
1857
+
1858
+ if new_path == src_path:
1859
+ return False
1860
+
1861
+ # Handle name conflicts
1862
+ if new_path in self.library.prompts:
1863
+ new_path = self._make_unique_filename(dest_folder, filename)
1864
+
1865
+ if not self.library.move_prompt(src_path, new_path):
1866
+ QMessageBox.warning(self.main_widget, "Move failed", "Could not move the prompt.")
1867
+ return False
1868
+
1869
+ # Update folder metadata and rewrite frontmatter in the moved file.
1870
+ try:
1871
+ prompt_data = self.library.prompts.get(new_path, {}).copy()
1872
+ prompt_data['folder'] = dest_folder
1873
+ prompt_data['modified'] = datetime.now().strftime('%Y-%m-%d')
1874
+ self.library.save_prompt(new_path, prompt_data)
1875
+ except Exception:
1876
+ pass
1877
+
1878
+ self.library.load_all_prompts()
1879
+ self._refresh_tree()
1880
+ self._select_and_reveal_prompt(new_path)
1881
+ return True
1882
+
1883
+ def _collapse_prompt_library_tree(self):
1884
+ """Collapse all folders in the Prompt Library tree."""
1885
+ if hasattr(self, 'tree_widget') and self.tree_widget:
1886
+ self.tree_widget.collapseAll()
1887
+
1888
+ def _expand_prompt_library_tree(self):
1889
+ """Expand all folders in the Prompt Library tree."""
1890
+ if hasattr(self, 'tree_widget') and self.tree_widget:
1891
+ self.tree_widget.expandAll()
1892
+
1893
+ def _build_tree_recursive(self, parent_item, directory: Path, relative_path: str):
1894
+ """Recursively build tree structure"""
1895
+ if not directory.exists():
1896
+ self.log_message(f"🔍 DEBUG: Directory doesn't exist: {directory}")
1897
+ return
1898
+
1899
+ # Get items sorted (folders first, then files)
1900
+ try:
1901
+ items = sorted(directory.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
1902
+ self.log_message(f"🔍 DEBUG: Found {len(items)} items in {directory.name}")
1903
+ except Exception as e:
1904
+ self.log_message(f"❌ ERROR listing directory {directory}: {e}")
1905
+ return
1906
+
1907
+ for item in items:
1908
+ if item.name.startswith('.') or item.name == '__pycache__':
1909
+ continue
1910
+
1911
+ if item.is_dir():
1912
+ # Folder
1913
+ rel_path = str(Path(relative_path) / item.name) if relative_path else item.name
1914
+ folder_item = QTreeWidgetItem([f"📁 {item.name}"])
1915
+ folder_item.setData(0, Qt.ItemDataRole.UserRole, {'type': 'folder', 'path': rel_path})
1916
+ folder_item.setFlags(folder_item.flags() | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsDropEnabled)
1917
+
1918
+ if parent_item:
1919
+ parent_item.addChild(folder_item)
1920
+ else:
1921
+ self.tree_widget.addTopLevelItem(folder_item)
1922
+
1923
+ self.log_message(f"🔍 DEBUG: Added folder: {item.name} (path: {rel_path})")
1924
+
1925
+ # Recurse
1926
+ self._build_tree_recursive(folder_item, item, rel_path)
1927
+
1928
+ elif item.suffix.lower() in ['.svprompt', '.md', '.txt']:
1929
+ # Prompt file (.svprompt is new format, .md/.txt legacy)
1930
+ rel_path = str(Path(relative_path) / item.name) if relative_path else item.name
1931
+
1932
+ self.log_message(f"🔍 DEBUG: Checking prompt file: {rel_path}")
1933
+ self.log_message(f"🔍 DEBUG: In library.prompts? {rel_path in self.library.prompts}")
1934
+
1935
+ # Show first few keys for comparison
1936
+ if len(self.library.prompts) > 0:
1937
+ sample_keys = list(self.library.prompts.keys())[:3]
1938
+ self.log_message(f"🔍 DEBUG: Sample keys: {sample_keys}")
1939
+
1940
+ if rel_path in self.library.prompts:
1941
+ prompt_data = self.library.prompts[rel_path]
1942
+ # Show full filename with extension in tree
1943
+ name = item.name # e.g., "prompt.svprompt"
1944
+
1945
+ prompt_item = QTreeWidgetItem([name])
1946
+ prompt_item.setData(0, Qt.ItemDataRole.UserRole, {'type': 'prompt', 'path': rel_path})
1947
+ prompt_item.setFlags(prompt_item.flags() | Qt.ItemFlag.ItemIsDragEnabled)
1948
+
1949
+ # Visual indicators
1950
+ indicators = []
1951
+ if prompt_data.get('favorite'):
1952
+ indicators.append("⭐")
1953
+ if prompt_data.get('quickmenu_quickmenu', prompt_data.get('quick_run', False)):
1954
+ indicators.append("⚡")
1955
+ if prompt_data.get('quickmenu_grid', False):
1956
+ indicators.append("🖱️")
1957
+ if indicators:
1958
+ prompt_item.setText(0, f"{' '.join(indicators)} {name}")
1959
+
1960
+ if parent_item:
1961
+ parent_item.addChild(prompt_item)
1962
+ else:
1963
+ self.tree_widget.addTopLevelItem(prompt_item)
1964
+
1965
+ self.log_message(f"🔍 DEBUG: Added prompt: {name}")
1966
+ else:
1967
+ self.log_message(f"⚠️ DEBUG: Prompt not in library.prompts: {rel_path}")
1968
+
1969
+ def _on_tree_item_clicked(self, item, column):
1970
+ """Handle tree item click"""
1971
+ data = item.data(0, Qt.ItemDataRole.UserRole)
1972
+
1973
+ if data and data.get('type') == 'prompt':
1974
+ self._load_prompt_in_editor(data['path'])
1975
+
1976
+ def _on_tree_item_double_clicked(self, item, column):
1977
+ """Handle tree item double-click - set as primary"""
1978
+ data = item.data(0, Qt.ItemDataRole.UserRole)
1979
+
1980
+ if data and data.get('type') == 'prompt':
1981
+ self._set_primary_prompt(data['path'])
1982
+
1983
+ def _show_tree_context_menu(self, position):
1984
+ """Show context menu for tree items"""
1985
+ item = self.tree_widget.itemAt(position)
1986
+ if not item:
1987
+ return
1988
+
1989
+ data = item.data(0, Qt.ItemDataRole.UserRole)
1990
+ if not data:
1991
+ return
1992
+
1993
+ menu = QMenu()
1994
+
1995
+ if data['type'] == 'prompt':
1996
+ path = data['path']
1997
+
1998
+ # Set as primary
1999
+ action_primary = menu.addAction("⭐ Set as Primary Prompt")
2000
+ action_primary.triggered.connect(lambda: self._set_primary_prompt(path))
2001
+
2002
+ # Attach/detach
2003
+ if path in self.library.attached_prompt_paths:
2004
+ action_attach = menu.addAction("❌ Detach from Active")
2005
+ action_attach.triggered.connect(lambda: self._detach_prompt(path))
2006
+ else:
2007
+ action_attach = menu.addAction("📎 Attach to Active")
2008
+ action_attach.triggered.connect(lambda: self._attach_prompt(path))
2009
+
2010
+ menu.addSeparator()
2011
+
2012
+ # Toggle favorite
2013
+ prompt_data = self.library.prompts.get(path, {})
2014
+ if prompt_data.get('favorite'):
2015
+ action_fav = menu.addAction("★ Remove from Favorites")
2016
+ else:
2017
+ action_fav = menu.addAction("☆ Add to Favorites")
2018
+ action_fav.triggered.connect(lambda: self._toggle_favorite(path))
2019
+
2020
+ # Toggle QuickMenu (legacy: quick_run)
2021
+ if prompt_data.get('quickmenu_quickmenu', prompt_data.get('quick_run', False)):
2022
+ action_qr = menu.addAction("⚡ Remove from QuickMenu")
2023
+ else:
2024
+ action_qr = menu.addAction("⚡ Add to QuickMenu")
2025
+ action_qr.triggered.connect(lambda: self._toggle_quick_run(path))
2026
+
2027
+ # Toggle Grid right-click QuickMenu
2028
+ if prompt_data.get('quickmenu_grid', False):
2029
+ action_grid = menu.addAction("🖱️ Remove from Grid QuickMenu")
2030
+ else:
2031
+ action_grid = menu.addAction("🖱️ Add to Grid QuickMenu")
2032
+ action_grid.triggered.connect(lambda: self._toggle_quickmenu_grid(path))
2033
+
2034
+ menu.addSeparator()
2035
+
2036
+ # Edit, duplicate, delete
2037
+ action_edit = menu.addAction("✏️ Edit")
2038
+ action_edit.triggered.connect(lambda: self._load_prompt_in_editor(path))
2039
+
2040
+ action_dup = menu.addAction("📋 Duplicate")
2041
+ action_dup.triggered.connect(lambda: self._duplicate_prompt(path))
2042
+
2043
+ action_del = menu.addAction("🗑️ Delete")
2044
+ action_del.triggered.connect(lambda: self._delete_prompt(path))
2045
+
2046
+ elif data['type'] == 'folder':
2047
+ # Folder operations
2048
+ action_new_prompt = menu.addAction("+ New Prompt in Folder")
2049
+ action_new_prompt.triggered.connect(lambda: self._new_prompt_in_folder(data['path']))
2050
+
2051
+ action_new_folder = menu.addAction("📁 New Subfolder")
2052
+ action_new_folder.triggered.connect(lambda: self._new_subfolder(data['path']))
2053
+
2054
+ menu.exec(self.tree_widget.viewport().mapToGlobal(position))
2055
+
2056
+ def _load_prompt_in_editor(self, relative_path: str):
2057
+ """Load prompt into editor for viewing/editing"""
2058
+ if relative_path not in self.library.prompts:
2059
+ return
2060
+
2061
+ prompt_data = self.library.prompts[relative_path]
2062
+
2063
+ # Show full filename with extension (e.g., "prompt.svprompt")
2064
+ filename = Path(relative_path).name
2065
+ self.editor_name_label.setText(f"Editing: {filename}")
2066
+ self.editor_name_input.setText(filename)
2067
+ self.editor_desc_input.setText(prompt_data.get('description', ''))
2068
+ if hasattr(self, 'editor_quickmenu_label_input'):
2069
+ self.editor_quickmenu_label_input.setText(prompt_data.get('quickmenu_label', '') or prompt_data.get('name', ''))
2070
+ if hasattr(self, 'editor_quickmenu_in_grid_cb'):
2071
+ self.editor_quickmenu_in_grid_cb.setChecked(bool(prompt_data.get('quickmenu_grid', False)))
2072
+ if hasattr(self, 'editor_quickmenu_in_quickmenu_cb'):
2073
+ self.editor_quickmenu_in_quickmenu_cb.setChecked(bool(prompt_data.get('quickmenu_quickmenu', prompt_data.get('quick_run', False))))
2074
+ self.editor_content.setPlainText(prompt_data.get('content', ''))
2075
+
2076
+ # Store current path for saving
2077
+ self.editor_current_path = relative_path
2078
+ self.btn_save_prompt.setEnabled(True)
2079
+
2080
+ # Hide external path display (this is a library prompt, not external)
2081
+ self.external_path_frame.setVisible(False)
2082
+ self._current_external_file_path = None
2083
+
2084
+ def _save_current_prompt(self):
2085
+ """Save currently edited prompt"""
2086
+ name = self.editor_name_input.text().strip()
2087
+ description = self.editor_desc_input.text().strip()
2088
+ content = self.editor_content.toPlainText().strip()
2089
+
2090
+ # Name field now represents the complete filename with extension
2091
+ # No stripping needed - user sees and edits the full filename
2092
+
2093
+ quickmenu_label = ''
2094
+ quickmenu_grid = False
2095
+ quickmenu_quickmenu = False
2096
+ if hasattr(self, 'editor_quickmenu_label_input'):
2097
+ quickmenu_label = self.editor_quickmenu_label_input.text().strip()
2098
+ if hasattr(self, 'editor_quickmenu_in_grid_cb'):
2099
+ quickmenu_grid = bool(self.editor_quickmenu_in_grid_cb.isChecked())
2100
+ if hasattr(self, 'editor_quickmenu_in_quickmenu_cb'):
2101
+ quickmenu_quickmenu = bool(self.editor_quickmenu_in_quickmenu_cb.isChecked())
2102
+
2103
+ if not name or not content:
2104
+ QMessageBox.warning(self.main_widget, "Error", "Name and content are required")
2105
+ return
2106
+
2107
+ # Check if this is a new prompt or editing existing
2108
+ if hasattr(self, 'editor_current_path') and self.editor_current_path:
2109
+ path = self.editor_current_path
2110
+
2111
+ # Handle external prompts (save back to external file)
2112
+ if path.startswith("[EXTERNAL] "):
2113
+ external_file_path = path[11:] # Remove "[EXTERNAL] " prefix
2114
+ self._save_external_prompt(external_file_path, name, description, content)
2115
+ return
2116
+
2117
+ # Editing existing library prompt
2118
+ if path not in self.library.prompts:
2119
+ QMessageBox.warning(self.main_widget, "Error", "Prompt no longer exists")
2120
+ return
2121
+
2122
+ prompt_data = self.library.prompts[path].copy()
2123
+ old_filename = Path(path).name
2124
+
2125
+ # Extract name without extension for metadata
2126
+ name_without_ext = Path(name).stem
2127
+
2128
+ prompt_data['name'] = name_without_ext
2129
+ prompt_data['description'] = description
2130
+ prompt_data['content'] = content
2131
+ prompt_data['quickmenu_label'] = quickmenu_label or name_without_ext
2132
+ prompt_data['quickmenu_grid'] = quickmenu_grid
2133
+ prompt_data['quickmenu_quickmenu'] = quickmenu_quickmenu
2134
+ # Keep legacy field in sync
2135
+ prompt_data['quick_run'] = quickmenu_quickmenu
2136
+
2137
+ # Check if filename changed - need to rename file
2138
+ if old_filename != name:
2139
+ from pathlib import Path
2140
+ old_path = Path(path)
2141
+ folder = str(old_path.parent) if old_path.parent != Path('.') else ''
2142
+ new_path = f"{folder}/{name}" if folder else name
2143
+
2144
+ # Delete old file and save to new location
2145
+ if self.library.delete_prompt(path):
2146
+ if self.library.save_prompt(new_path, prompt_data):
2147
+ self.library.load_all_prompts()
2148
+ self._refresh_tree()
2149
+ self._select_and_reveal_prompt(new_path)
2150
+ self.editor_current_path = new_path # Update to new path
2151
+ QMessageBox.information(self.main_widget, "Saved", f"Prompt renamed to '{name}' successfully!")
2152
+ self.log_message(f"✓ Renamed prompt: {old_filename} → {name}")
2153
+ else:
2154
+ QMessageBox.warning(self.main_widget, "Error", "Failed to rename prompt")
2155
+ else:
2156
+ QMessageBox.warning(self.main_widget, "Error", "Failed to delete old prompt file")
2157
+ else:
2158
+ # Name unchanged, just update in place
2159
+ if self.library.save_prompt(path, prompt_data):
2160
+ QMessageBox.information(self.main_widget, "Saved", "Prompt updated successfully!")
2161
+ self._refresh_tree()
2162
+ else:
2163
+ QMessageBox.warning(self.main_widget, "Error", "Failed to save prompt")
2164
+ else:
2165
+ # Creating new prompt
2166
+ folder = getattr(self, 'editor_target_folder', 'Project Prompts')
2167
+
2168
+ # Create new prompt data
2169
+ from datetime import datetime
2170
+ prompt_data = {
2171
+ 'name': name,
2172
+ 'description': description,
2173
+ 'content': content,
2174
+ 'domain': '',
2175
+ 'version': '1.0',
2176
+ 'task_type': 'Translation',
2177
+ 'favorite': False,
2178
+ # QuickMenu
2179
+ 'quickmenu_label': quickmenu_label or name,
2180
+ 'quickmenu_grid': quickmenu_grid,
2181
+ 'quickmenu_quickmenu': quickmenu_quickmenu,
2182
+ # Legacy
2183
+ 'quick_run': quickmenu_quickmenu,
2184
+ 'folder': folder,
2185
+ 'tags': [],
2186
+ 'created': datetime.now().strftime('%Y-%m-%d'),
2187
+ 'modified': datetime.now().strftime('%Y-%m-%d')
2188
+ }
2189
+
2190
+ # Create the prompt file (save_prompt creates new file if it doesn't exist)
2191
+ relative_path = f"{folder}/{name}.svprompt"
2192
+ if self.library.save_prompt(relative_path, prompt_data):
2193
+ QMessageBox.information(self.main_widget, "Created", f"Prompt '{name}' created successfully!")
2194
+ self.library.load_all_prompts() # Reload to get new prompt in memory
2195
+ self._refresh_tree()
2196
+ self.editor_current_path = relative_path # Now editing this prompt
2197
+ else:
2198
+ QMessageBox.warning(self.main_widget, "Error", "Failed to create prompt")
2199
+
2200
+ def _save_external_prompt(self, file_path: str, name: str, description: str, content: str):
2201
+ """Save changes to an external prompt file"""
2202
+ from pathlib import Path
2203
+ import json
2204
+
2205
+ path = Path(file_path)
2206
+
2207
+ try:
2208
+ if file_path.lower().endswith('.svprompt'):
2209
+ # Save as JSON format
2210
+ data = {
2211
+ 'name': name,
2212
+ 'description': description,
2213
+ 'content': content,
2214
+ 'version': '1.0'
2215
+ }
2216
+ path.write_text(json.dumps(data, indent=2), encoding='utf-8')
2217
+ else:
2218
+ # Save as plain text
2219
+ path.write_text(content, encoding='utf-8')
2220
+
2221
+ # Update the library's active primary prompt content
2222
+ self.library.active_primary_prompt = content
2223
+
2224
+ QMessageBox.information(self.main_widget, "Saved", f"External prompt '{name}' saved successfully!")
2225
+ self.log_message(f"✓ Saved external prompt: {name}")
2226
+
2227
+ except Exception as e:
2228
+ QMessageBox.warning(self.main_widget, "Error", f"Failed to save external prompt: {e}")
2229
+
2230
+ def _set_primary_prompt(self, relative_path: str):
2231
+ """Set prompt as primary"""
2232
+ if self.library.set_primary_prompt(relative_path):
2233
+ prompt_data = self.library.prompts[relative_path]
2234
+ self.primary_prompt_label.setText(prompt_data.get('name', 'Unnamed'))
2235
+ self.primary_prompt_label.setStyleSheet("color: #000; font-weight: bold;")
2236
+ self.log_message(f"✓ Set primary: {prompt_data.get('name')}")
2237
+ # Also display in the editor
2238
+ self._load_prompt_in_editor(relative_path)
2239
+
2240
+ def _attach_prompt(self, relative_path: str):
2241
+ """Attach prompt to active configuration"""
2242
+ if self.library.attach_prompt(relative_path):
2243
+ self._update_attached_list()
2244
+ prompt_data = self.library.prompts[relative_path]
2245
+ self.log_message(f"✓ Attached: {prompt_data.get('name')}")
2246
+
2247
+ def _detach_prompt(self, relative_path: str):
2248
+ """Detach prompt from active configuration"""
2249
+ if self.library.detach_prompt(relative_path):
2250
+ self._update_attached_list()
2251
+ self.log_message(f"✓ Detached prompt")
2252
+
2253
+ def _update_attached_list(self):
2254
+ """Update the attached prompts list widget"""
2255
+ self.attached_list_widget.clear()
2256
+
2257
+ for path in self.library.attached_prompt_paths:
2258
+ if path in self.library.prompts:
2259
+ prompt_data = self.library.prompts[path]
2260
+ name = prompt_data.get('name', 'Unnamed')
2261
+
2262
+ item = QTreeWidgetItem([name, "×"])
2263
+ item.setData(0, Qt.ItemDataRole.UserRole, path)
2264
+
2265
+ self.attached_list_widget.addTopLevelItem(item)
2266
+
2267
+ def _clear_primary_prompt(self):
2268
+ """Clear primary prompt selection"""
2269
+ self.library.active_primary_prompt = None
2270
+ self.library.active_primary_prompt_path = None
2271
+ self.primary_prompt_label.setText("[None selected]")
2272
+ self.primary_prompt_label.setStyleSheet("color: #999;")
2273
+ self.log_message("✓ Cleared primary prompt")
2274
+
2275
+ def _load_external_primary_prompt(self):
2276
+ """Load an external prompt file (not in library) as primary"""
2277
+ file_path, _ = QFileDialog.getOpenFileName(
2278
+ self.main_widget,
2279
+ "Select External Prompt File",
2280
+ "",
2281
+ "Prompt Files (*.svprompt *.txt *.md);;Supervertaler Prompts (*.svprompt);;Text Files (*.txt);;Markdown Files (*.md);;All Files (*.*)"
2282
+ )
2283
+
2284
+ if not file_path:
2285
+ return # User cancelled
2286
+
2287
+ success, result = self.library.set_external_primary_prompt(file_path)
2288
+
2289
+ if success:
2290
+ # result is the display name
2291
+ self.primary_prompt_label.setText(f"📁 {result}")
2292
+ self.primary_prompt_label.setStyleSheet("color: #0066cc; font-weight: bold;")
2293
+ self.primary_prompt_label.setToolTip(f"External file: {file_path}")
2294
+ self.log_message(f"✓ Loaded external prompt: {result}")
2295
+
2296
+ # Display the external prompt in the editor
2297
+ self._display_external_prompt_in_editor(file_path, result)
2298
+ else:
2299
+ # result is the error message
2300
+ QMessageBox.warning(self.main_widget, "Error", f"Could not load file: {result}")
2301
+
2302
+ def _display_external_prompt_in_editor(self, file_path: str, display_name: str):
2303
+ """Display an external prompt file in the editor (read-only view)"""
2304
+ from pathlib import Path
2305
+ import json
2306
+
2307
+ path = Path(file_path)
2308
+
2309
+ try:
2310
+ content = path.read_text(encoding='utf-8')
2311
+ description = ""
2312
+
2313
+ # Try to parse as JSON (.svprompt format)
2314
+ if file_path.lower().endswith('.svprompt'):
2315
+ try:
2316
+ data = json.loads(content)
2317
+ # Extract content and description from svprompt
2318
+ content = data.get('content', content)
2319
+ description = data.get('description', '')
2320
+ except json.JSONDecodeError:
2321
+ pass # Keep raw content
2322
+
2323
+ # Update editor fields
2324
+ self.editor_name_label.setText(f"📁 External: {display_name}")
2325
+ self.editor_name_input.setText(display_name)
2326
+ self.editor_desc_input.setText(description)
2327
+ self.editor_content.setPlainText(content)
2328
+
2329
+ # Store the external path for potential save operations
2330
+ self.editor_current_path = f"[EXTERNAL] {file_path}"
2331
+ self._current_external_file_path = file_path # Store for folder opening
2332
+ self.btn_save_prompt.setEnabled(True)
2333
+
2334
+ # Show the external path with clickable link
2335
+ self.external_path_label.setText(file_path)
2336
+ self.external_path_frame.setVisible(True)
2337
+
2338
+ except Exception as e:
2339
+ self.log_message(f"⚠ Could not display prompt in editor: {e}")
2340
+
2341
+ def _open_external_prompt_folder(self, event):
2342
+ """Open the folder containing the current external prompt file"""
2343
+ import subprocess
2344
+ import platform
2345
+ from pathlib import Path
2346
+
2347
+ if not hasattr(self, '_current_external_file_path') or not self._current_external_file_path:
2348
+ return
2349
+
2350
+ folder_path = Path(self._current_external_file_path).parent
2351
+
2352
+ if not folder_path.exists():
2353
+ QMessageBox.warning(self.main_widget, "Folder Not Found", f"The folder no longer exists:\n{folder_path}")
2354
+ return
2355
+
2356
+ try:
2357
+ if platform.system() == 'Windows':
2358
+ # Open folder and select the file
2359
+ subprocess.run(['explorer', '/select,', str(self._current_external_file_path)])
2360
+ elif platform.system() == 'Darwin': # macOS
2361
+ subprocess.run(['open', '-R', str(self._current_external_file_path)])
2362
+ else: # Linux
2363
+ subprocess.run(['xdg-open', str(folder_path)])
2364
+ except Exception as e:
2365
+ QMessageBox.warning(self.main_widget, "Error", f"Could not open folder: {e}")
2366
+
2367
+ def _clear_all_attachments(self):
2368
+ """Clear all attached prompts"""
2369
+ self.library.clear_attachments()
2370
+ self._update_attached_list()
2371
+ self.log_message("✓ Cleared all attachments")
2372
+
2373
+ def _toggle_favorite(self, relative_path: str):
2374
+ """Toggle favorite status"""
2375
+ if self.library.toggle_favorite(relative_path):
2376
+ self._refresh_tree()
2377
+
2378
+ def _toggle_quick_run(self, relative_path: str):
2379
+ """Toggle QuickMenu (future app menu) status (legacy name: quick_run)."""
2380
+ if self.library.toggle_quick_run(relative_path):
2381
+ self._refresh_tree()
2382
+
2383
+ def _toggle_quickmenu_grid(self, relative_path: str):
2384
+ """Toggle whether this prompt appears in the Grid right-click QuickMenu."""
2385
+ if self.library.toggle_quickmenu_grid(relative_path):
2386
+ self._refresh_tree()
2387
+
2388
+ def _new_prompt(self):
2389
+ """Create new prompt in the currently selected folder."""
2390
+ self._new_prompt_in_folder(self._get_selected_folder_for_new_prompt())
2391
+
2392
+ def _new_folder(self):
2393
+ """Create new folder"""
2394
+ name, ok = QInputDialog.getText(self.main_widget, "New Folder", "Enter folder name:")
2395
+ if ok and name:
2396
+ if self.library.create_folder(name):
2397
+ self._refresh_tree()
2398
+
2399
+ def _new_prompt_in_folder(self, folder_path: str):
2400
+ """Create new prompt in specific folder"""
2401
+ name, ok = QInputDialog.getText(self.main_widget, "New Prompt", "Enter prompt filename with extension (e.g., prompt.svprompt):")
2402
+ if not ok or not name:
2403
+ return
2404
+
2405
+ # Ensure .svprompt extension
2406
+ if not name.endswith('.svprompt'):
2407
+ name = f"{name}.svprompt"
2408
+
2409
+ # Extract name without extension for metadata
2410
+ name_without_ext = Path(name).stem
2411
+
2412
+ # Create the prompt immediately
2413
+ from datetime import datetime
2414
+ prompt_data = {
2415
+ 'name': name_without_ext,
2416
+ 'description': '',
2417
+ 'content': '# Your prompt content here\n\nProvide translation instructions...',
2418
+ 'domain': '',
2419
+ 'version': '1.0',
2420
+ 'task_type': 'Translation',
2421
+ 'favorite': False,
2422
+ 'quickmenu_label': name_without_ext,
2423
+ 'quickmenu_grid': False,
2424
+ 'quickmenu_quickmenu': False,
2425
+ 'quick_run': False,
2426
+ 'folder': folder_path,
2427
+ 'tags': [],
2428
+ 'created': datetime.now().strftime('%Y-%m-%d'),
2429
+ 'modified': datetime.now().strftime('%Y-%m-%d')
2430
+ }
2431
+
2432
+ # Create .svprompt file (name already includes .svprompt extension)
2433
+ relative_path = f"{folder_path}/{name}" if folder_path else name
2434
+
2435
+ if self.library.save_prompt(relative_path, prompt_data):
2436
+ self.library.load_all_prompts() # Reload to get new prompt in memory
2437
+ self._refresh_tree()
2438
+ self._select_and_reveal_prompt(relative_path)
2439
+ self._load_prompt_in_editor(relative_path)
2440
+ self.log_message(f"✓ Created new prompt '{name}' in folder: {folder_path}")
2441
+ else:
2442
+ QMessageBox.warning(self.main_widget, "Error", "Failed to create prompt")
2443
+
2444
+ def _new_subfolder(self, parent_folder: str):
2445
+ """Create subfolder"""
2446
+ name, ok = QInputDialog.getText(self.main_widget, "New Subfolder", "Enter folder name:")
2447
+ if ok and name:
2448
+ full_path = str(Path(parent_folder) / name)
2449
+ if self.library.create_folder(full_path):
2450
+ self._refresh_tree()
2451
+
2452
+ def _duplicate_prompt(self, relative_path: str):
2453
+ """Duplicate a prompt into the same folder with a unique filename."""
2454
+ if relative_path not in self.library.prompts:
2455
+ return
2456
+
2457
+ src_data = self.library.prompts[relative_path].copy()
2458
+ src_name = src_data.get('name', Path(relative_path).stem)
2459
+
2460
+ folder = str(Path(relative_path).parent)
2461
+ if folder == '.':
2462
+ folder = ''
2463
+
2464
+ filename = Path(relative_path).name
2465
+ new_path = self._make_unique_filename(folder, filename)
2466
+ new_name = Path(new_path).stem
2467
+
2468
+ src_data['name'] = new_name
2469
+ src_data['favorite'] = False
2470
+ src_data['quick_run'] = False
2471
+ src_data['quickmenu_grid'] = False
2472
+ src_data['quickmenu_quickmenu'] = False
2473
+ src_data['folder'] = folder
2474
+ src_data['created'] = datetime.now().strftime('%Y-%m-%d')
2475
+ src_data['modified'] = datetime.now().strftime('%Y-%m-%d')
2476
+
2477
+ if self.library.save_prompt(new_path, src_data):
2478
+ self.library.load_all_prompts()
2479
+ self._refresh_tree()
2480
+ self._select_and_reveal_prompt(new_path)
2481
+ self._load_prompt_in_editor(new_path)
2482
+ self.log_message(f"✓ Duplicated: {src_name} → {new_name} (saved as .svprompt)")
2483
+ else:
2484
+ QMessageBox.warning(self.main_widget, "Duplicate failed", "Failed to duplicate the prompt.")
2485
+
2486
+ def _delete_prompt(self, relative_path: str):
2487
+ """Delete a prompt"""
2488
+ reply = QMessageBox.question(
2489
+ self.main_widget,
2490
+ "Delete Prompt",
2491
+ "Are you sure you want to delete this prompt?",
2492
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
2493
+ )
2494
+
2495
+ if reply == QMessageBox.StandardButton.Yes:
2496
+ if self.library.delete_prompt(relative_path):
2497
+ self._refresh_tree()
2498
+ self.log_message("✓ Prompt deleted")
2499
+
2500
+ def _refresh_library(self):
2501
+ """Reload library and refresh UI"""
2502
+ self.library.load_all_prompts()
2503
+ self._refresh_tree()
2504
+ self._update_attached_list()
2505
+ self.log_message("✓ Library refreshed")
2506
+
2507
+ def _preview_combined_prompt(self):
2508
+ """Preview the combined prompt with actual segment text"""
2509
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QPushButton, QHBoxLayout, QMessageBox
2510
+
2511
+ # Get current segment from the app
2512
+ current_segment = None
2513
+ current_segment_id = "Preview"
2514
+ source_lang = "Source Language"
2515
+ target_lang = "Target Language"
2516
+
2517
+ # Try to get segment from main app
2518
+ if hasattr(self, 'parent_app') and self.parent_app:
2519
+ # Get languages if project loaded
2520
+ if hasattr(self.parent_app, 'current_project') and self.parent_app.current_project:
2521
+ source_lang = getattr(self.parent_app.current_project, 'source_lang', 'Source Language')
2522
+ target_lang = getattr(self.parent_app.current_project, 'target_lang', 'Target Language')
2523
+
2524
+ # Try to get selected segment
2525
+ if hasattr(self.parent_app, 'table') and self.parent_app.table:
2526
+ current_row = self.parent_app.table.currentRow()
2527
+ if current_row >= 0:
2528
+ # Map display row to actual segment index
2529
+ actual_index = current_row
2530
+ if hasattr(self.parent_app, 'grid_row_to_segment_index') and self.parent_app.grid_row_to_segment_index:
2531
+ if current_row in self.parent_app.grid_row_to_segment_index:
2532
+ actual_index = self.parent_app.grid_row_to_segment_index[current_row]
2533
+
2534
+ # Get segment
2535
+ if actual_index < len(self.parent_app.current_project.segments):
2536
+ current_segment = self.parent_app.current_project.segments[actual_index]
2537
+ current_segment_id = f"Segment {current_segment.id}"
2538
+
2539
+ # Fallback to first segment if none selected
2540
+ if not current_segment and len(self.parent_app.current_project.segments) > 0:
2541
+ current_segment = self.parent_app.current_project.segments[0]
2542
+ current_segment_id = f"Example: Segment {current_segment.id}"
2543
+
2544
+ # Get source text
2545
+ if current_segment:
2546
+ source_text = current_segment.source
2547
+ else:
2548
+ source_text = "{{SOURCE_TEXT}}"
2549
+ QMessageBox.information(
2550
+ self.main_widget,
2551
+ "No Segment Selected",
2552
+ "No segment is currently selected. Showing template with placeholder text.\n\n"
2553
+ "To see the actual prompt with your text, please select a segment first."
2554
+ )
2555
+
2556
+ # Build combined prompt
2557
+ combined = self.build_final_prompt(source_text, source_lang, target_lang)
2558
+
2559
+ # Build composition info
2560
+ composition_parts = []
2561
+ composition_parts.append(f"📍 Segment: {current_segment_id}")
2562
+ composition_parts.append(f"🌐 Languages: {source_lang} → {target_lang}")
2563
+ composition_parts.append(f"📏 Total prompt length: {len(combined):,} characters")
2564
+
2565
+ if self.library.active_primary_prompt:
2566
+ composition_parts.append(f"✓ Primary prompt attached")
2567
+
2568
+ if self.library.attached_prompts:
2569
+ composition_parts.append(f"✓ {len(self.library.attached_prompts)} additional prompt(s) attached")
2570
+
2571
+ composition_text = "\n".join(composition_parts)
2572
+
2573
+ # Create custom dialog with proper text editor
2574
+ dialog = QDialog(self.main_widget)
2575
+ dialog.setWindowTitle("🧪 Combined Prompt Preview")
2576
+ dialog.resize(900, 700) # Larger default size
2577
+
2578
+ layout = QVBoxLayout(dialog)
2579
+
2580
+ # Info label
2581
+ info_label = QLabel(
2582
+ "<b>Complete Assembled Prompt</b><br>"
2583
+ "This is what will be sent to the AI (System Prompt + Custom Prompts + your text)<br><br>" +
2584
+ composition_text.replace("\n", "<br>")
2585
+ )
2586
+ info_label.setTextFormat(Qt.TextFormat.RichText)
2587
+ info_label.setWordWrap(True)
2588
+ info_label.setStyleSheet("padding: 10px; background-color: #e3f2fd; border-radius: 4px; margin-bottom: 10px;")
2589
+ layout.addWidget(info_label)
2590
+
2591
+ # Text editor for preview
2592
+ text_edit = QTextEdit()
2593
+ text_edit.setPlainText(combined)
2594
+ text_edit.setReadOnly(True)
2595
+ text_edit.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
2596
+ text_edit.setStyleSheet("font-family: 'Consolas', 'Courier New', monospace; font-size: 9pt;")
2597
+ layout.addWidget(text_edit, 1) # Stretch factor 1
2598
+
2599
+ # Close button
2600
+ button_layout = QHBoxLayout()
2601
+ button_layout.addStretch()
2602
+ close_btn = QPushButton("Close")
2603
+ close_btn.clicked.connect(dialog.accept)
2604
+ close_btn.setStyleSheet("padding: 8px 20px; font-weight: bold;")
2605
+ button_layout.addWidget(close_btn)
2606
+ layout.addLayout(button_layout)
2607
+
2608
+ dialog.exec()
2609
+
2610
+ def _view_current_system_template(self):
2611
+ """View the current system prompt"""
2612
+ template = self.get_system_template(self.current_mode)
2613
+
2614
+ dialog = QMessageBox(self.main_widget)
2615
+ dialog.setWindowTitle(f"System Prompt: {self._get_mode_display_name()}")
2616
+ dialog.setText(f"Current system prompt for {self._get_mode_display_name()} mode:")
2617
+ dialog.setDetailedText(template)
2618
+ dialog.setIcon(QMessageBox.Icon.Information)
2619
+ dialog.exec()
2620
+
2621
+ def _open_system_prompts_settings(self):
2622
+ """Open system prompts in settings"""
2623
+ try:
2624
+ # Navigate to Settings tab if main app has the method
2625
+ # Use parent_app (not app)
2626
+ if hasattr(self.parent_app, 'main_tabs') and hasattr(self.parent_app, 'settings_tabs'):
2627
+ # Navigate to Settings tab (index 3)
2628
+ self.parent_app.main_tabs.setCurrentIndex(3)
2629
+ # Navigate to System Prompts sub-tab (index 5 - after General, LLM, Language, MT, View)
2630
+ # Verify the index is valid before setting it
2631
+ if self.parent_app.settings_tabs.count() > 5:
2632
+ self.parent_app.settings_tabs.setCurrentIndex(5)
2633
+ else:
2634
+ # Log warning and fall back to first tab
2635
+ print(f"[WARNING] settings_tabs only has {self.parent_app.settings_tabs.count()} tabs, cannot navigate to index 5")
2636
+ self.parent_app.settings_tabs.setCurrentIndex(0)
2637
+ QMessageBox.warning(
2638
+ self.main_widget,
2639
+ "Navigation Issue",
2640
+ f"Could not navigate to System Prompts tab (expected at index 5, but only {self.parent_app.settings_tabs.count()} tabs exist).\n\n"
2641
+ "Please manually navigate to Settings → System Prompts."
2642
+ )
2643
+ else:
2644
+ # Fallback message
2645
+ QMessageBox.information(
2646
+ self.main_widget,
2647
+ "System Prompts",
2648
+ "System Prompts (Layer 1) are configured in Settings → System Prompts tab.\n\n"
2649
+ "They are automatically selected based on the document type you're processing."
2650
+ )
2651
+ except Exception as e:
2652
+ import traceback
2653
+ error_msg = f"Error opening System Prompts settings: {str(e)}"
2654
+ print(f"[ERROR] {error_msg}")
2655
+ print(traceback.format_exc())
2656
+ QMessageBox.critical(
2657
+ self.main_widget,
2658
+ "Error",
2659
+ f"Failed to open System Prompts settings:\n\n{str(e)}\n\n"
2660
+ "Please manually navigate to Settings → System Prompts tab."
2661
+ )
2662
+
2663
+ # === System Prompts Management ===
2664
+
2665
+ def _load_system_templates(self):
2666
+ """Load system prompts from files"""
2667
+ system_templates_dir = self.prompt_library_dir / "1_System_Prompts"
2668
+
2669
+ if system_templates_dir.exists():
2670
+ # Load from old location if exists (migration support)
2671
+ file_map = {
2672
+ "Single Segment Translation (system prompt).md": "single",
2673
+ "Batch DOCX Translation (system prompt).md": "batch_docx",
2674
+ "Batch Bilingual Translation (system prompt).md": "batch_bilingual"
2675
+ }
2676
+
2677
+ for filename, mode in file_map.items():
2678
+ filepath = system_templates_dir / filename
2679
+ if filepath.exists():
2680
+ self.system_templates[mode] = filepath.read_text(encoding='utf-8')
2681
+
2682
+ # Fill missing with defaults
2683
+ for mode in ["single", "batch_docx", "batch_bilingual"]:
2684
+ if mode not in self.system_templates:
2685
+ self.system_templates[mode] = self._get_default_system_template(mode)
2686
+
2687
+ def _get_default_system_template(self, mode: str) -> str:
2688
+ """Get default system prompt for a mode"""
2689
+ # Comprehensive system prompt with detailed CAT tag instructions
2690
+ return """# SYSTEM PROMPT
2691
+
2692
+ ⚠️ **PROFESSIONAL TRANSLATION CONTEXT:**
2693
+ You are performing professional technical/medical translation as a licensed service for a commercial translation company. This is legitimate professional work. All medical, technical, or specialized terminology is for professional translation purposes only.
2694
+
2695
+ You are an expert {{SOURCE_LANGUAGE}} to {{TARGET_LANGUAGE}} translator with deep understanding of context and nuance.
2696
+
2697
+ **YOUR TASK**: Translate the text below.
2698
+
2699
+ **IMPORTANT INSTRUCTIONS**:
2700
+ - Provide ONLY the translated text
2701
+ - Do NOT include numbering, labels, or commentary
2702
+ - Do NOT repeat the source text
2703
+ - Maintain accuracy and natural fluency
2704
+
2705
+ **CRITICAL: INLINE FORMATTING TAG PRESERVATION**:
2706
+ - Source text may contain simple HTML-style formatting tags: <b>bold</b>, <i>italic</i>, <u>underline</u>
2707
+ - These tags represent text formatting that MUST be preserved in the translation
2708
+ - Place the tags around the CORRESPONDING translated words, not necessarily in the same position
2709
+ - Example: "Click the <b>Save</b> button" → "Klik op de knop <b>Opslaan</b>"
2710
+ - Ensure every opening tag has a matching closing tag
2711
+ - Never omit, add, or modify tags - preserve the exact same tags from source
2712
+
2713
+ **CRITICAL: CAT TOOL TAG PRESERVATION**:
2714
+ - Source may contain CAT tool formatting tags in various formats:
2715
+ • memoQ: [1}, {2], [3}, {4] (asymmetric bracket-brace pairs)
2716
+ • Trados Studio: <410>text</410>, <434>text</434> (XML-style opening/closing tags)
2717
+ • CafeTran: |formatted text| (pipe symbols mark formatted text - bold, italic, underline, etc.)
2718
+ • Other CAT tools: various bracketed or special character sequences
2719
+ - These are placeholder tags representing formatting (bold, italic, links, etc.)
2720
+ - PRESERVE ALL tags - if source has N tags, target must have exactly N tags
2721
+ - Keep tags with their content and adjust position for natural target language word order
2722
+ - Never translate, omit, or modify the tags themselves - only reposition them
2723
+ - Examples:
2724
+ • memoQ: '[1}De uitvoer{2]' → '[1}The exports{2]'
2725
+ • Trados: '<410>De uitvoer van machines</410>' → '<410>Exports of machinery</410>'
2726
+ • CafeTran: 'He debuted against |Juventus FC| in 2001' → 'Hij debuteerde tegen |Juventus FC| in 2001'
2727
+ • Multiple: '[1}De uitvoer{2] [3}stelt niets voor{4]' → '[1}Exports{2] [3}mean nothing{4]'
2728
+
2729
+ **LANGUAGE-SPECIFIC NUMBER FORMATTING**:
2730
+ - If the target language is **Dutch**, **French**, **German**, **Italian**, **Spanish**, or another **continental European language**, use a **comma** as the decimal separator and a **space or non-breaking space** between the number and unit (e.g., 17,1 cm).
2731
+ - If the target language is **English** or **Irish**, use a **full stop (period)** as the decimal separator and **no space** before the unit (e.g., 17.1 cm).
2732
+ - Always follow the **number formatting conventions** of the target language.
2733
+
2734
+ If the text refers to figures (e.g., 'Figure 1A'), relevant images may be provided for visual context.
2735
+
2736
+ {{SOURCE_LANGUAGE}} text:
2737
+ {{SOURCE_TEXT}}"""
2738
+
2739
+ def get_system_template(self, mode: str) -> str:
2740
+ """Get system prompt for specified mode"""
2741
+ return self.system_templates.get(mode, self._get_default_system_template(mode))
2742
+
2743
+ def set_mode(self, mode: str):
2744
+ """Set current translation mode (single, batch_docx, batch_bilingual)"""
2745
+ if mode in ["single", "batch_docx", "batch_bilingual"]:
2746
+ self.current_mode = mode
2747
+ if hasattr(self, 'mode_label'):
2748
+ self.mode_label.setText(f"Mode: {self._get_mode_display_name()}")
2749
+
2750
+ def update_image_context_display(self):
2751
+ """Update the Image Context label in Active Configuration panel"""
2752
+ if not hasattr(self, 'image_context_label'):
2753
+ return
2754
+
2755
+ # Check if parent app has figure_context
2756
+ if hasattr(self, 'parent_app') and self.parent_app:
2757
+ if hasattr(self.parent_app, 'figure_context') and self.parent_app.figure_context:
2758
+ fc = self.parent_app.figure_context
2759
+ if fc.has_images():
2760
+ count = fc.get_image_count()
2761
+ folder_name = fc.get_folder_name() or "folder"
2762
+ self.image_context_label.setText(f"✅ {count} image{'s' if count != 1 else ''} from: {folder_name}")
2763
+ self.image_context_label.setStyleSheet("color: #4CAF50; font-weight: bold;")
2764
+ return
2765
+
2766
+ # No images loaded
2767
+ self.image_context_label.setText("[None loaded]")
2768
+ self.image_context_label.setStyleSheet("color: #999;")
2769
+
2770
+ # === Prompt Composition (for translation) ===
2771
+
2772
+ def build_final_prompt(self, source_text: str, source_lang: str, target_lang: str, mode: str = None) -> str:
2773
+ """
2774
+ Build final prompt for translation using 2-layer architecture:
2775
+ 1. System Prompt (auto-selected by mode)
2776
+ 2. Combined prompts from library (primary + attached)
2777
+
2778
+ Args:
2779
+ source_text: Text to translate
2780
+ source_lang: Source language
2781
+ target_lang: Target language
2782
+ mode: Override mode (if None, uses self.current_mode)
2783
+
2784
+ Returns:
2785
+ Complete prompt ready for LLM
2786
+ """
2787
+ if mode is None:
2788
+ mode = self.current_mode
2789
+
2790
+ # Layer 1: System Prompt
2791
+ system_template = self.get_system_template(mode)
2792
+
2793
+ # Replace placeholders in system prompt
2794
+ system_template = system_template.replace("{{SOURCE_LANGUAGE}}", source_lang)
2795
+ system_template = system_template.replace("{{TARGET_LANGUAGE}}", target_lang)
2796
+ system_template = system_template.replace("{{SOURCE_TEXT}}", source_text)
2797
+
2798
+ # Layer 2: Library prompts (primary + attached)
2799
+ library_prompts = ""
2800
+
2801
+ if self.library.active_primary_prompt:
2802
+ library_prompts += "\n\n# PRIMARY INSTRUCTIONS\n\n"
2803
+ library_prompts += self.library.active_primary_prompt
2804
+
2805
+ for attached_content in self.library.attached_prompts:
2806
+ library_prompts += "\n\n# ADDITIONAL INSTRUCTIONS\n\n"
2807
+ library_prompts += attached_content
2808
+
2809
+ # Combine
2810
+ final_prompt = system_template + library_prompts
2811
+
2812
+ # Add translation delimiter
2813
+ final_prompt += "\n\n**YOUR TRANSLATION (provide ONLY the translated text, no numbering or labels):**\n"
2814
+
2815
+ return final_prompt
2816
+
2817
+ # ============================================================================
2818
+ # AI ASSISTANT METHODS
2819
+ # ============================================================================
2820
+
2821
+ def _init_llm_client(self):
2822
+ """Initialize LLM client with available API keys"""
2823
+ try:
2824
+ # Use parent app's API key loading method (reads from user_data/api_keys.txt)
2825
+ if hasattr(self.parent_app, 'load_api_keys'):
2826
+ api_keys = self.parent_app.load_api_keys()
2827
+ else:
2828
+ # Fallback to module function (legacy path)
2829
+ api_keys = load_api_keys()
2830
+
2831
+ # Debug: Log which keys were found (without exposing the actual keys)
2832
+ key_names = [k for k in api_keys.keys() if api_keys.get(k)]
2833
+ if key_names:
2834
+ self.log_message(f"🔑 Found API keys for: {', '.join(key_names)}")
2835
+ else:
2836
+ self.log_message("⚠ No API keys found in api_keys.txt")
2837
+
2838
+ # Try to use the same provider as main app if available
2839
+ provider = None
2840
+ model = None
2841
+
2842
+ # Check parent app settings
2843
+ if hasattr(self.parent_app, 'current_provider'):
2844
+ provider = self.parent_app.current_provider
2845
+ if hasattr(self.parent_app, 'current_model'):
2846
+ model = self.parent_app.current_model
2847
+
2848
+ # Fallback: use first available API key
2849
+ if not provider:
2850
+ if api_keys.get("openai"):
2851
+ provider = "openai"
2852
+ elif api_keys.get("claude"):
2853
+ provider = "claude"
2854
+ elif api_keys.get("google") or api_keys.get("gemini"):
2855
+ provider = "gemini"
2856
+
2857
+ if provider:
2858
+ # Map provider names to API key names (gemini uses 'google' key)
2859
+ key_name = "google" if provider == "gemini" else provider
2860
+ api_key = api_keys.get(key_name) or api_keys.get("gemini") or api_keys.get("openai") or api_keys.get("claude") or api_keys.get("google")
2861
+ if api_key:
2862
+ self.llm_client = LLMClient(
2863
+ api_key=api_key,
2864
+ provider=provider,
2865
+ model=model,
2866
+ max_tokens=16384
2867
+ )
2868
+ self.log_message(f"✓ AI Assistant initialized with {provider}")
2869
+ else:
2870
+ self.log_message("⚠ No API keys found for AI Assistant")
2871
+ else:
2872
+ self.log_message("⚠ No LLM provider configured")
2873
+
2874
+ except Exception as e:
2875
+ self.log_message(f"⚠ Failed to initialize AI Assistant: {e}")
2876
+
2877
+ def _load_conversation_history(self):
2878
+ """Load previous conversation from disk"""
2879
+ try:
2880
+ if self.ai_conversation_file.exists():
2881
+ with open(self.ai_conversation_file, 'r', encoding='utf-8') as f:
2882
+ data = json.load(f)
2883
+ self.chat_history = data.get('history', [])
2884
+ # Don't load files from JSON - they're loaded from AttachmentManager
2885
+
2886
+ # Restore chat display
2887
+ if hasattr(self, 'chat_display'):
2888
+ for msg in self.chat_history[-10:]: # Show last 10 messages
2889
+ self._add_chat_message(msg['role'], msg['content'], save=False)
2890
+
2891
+ # Refresh attached files list after UI is created
2892
+ if hasattr(self, 'attached_files_list_layout'):
2893
+ self._refresh_attached_files_list()
2894
+
2895
+ except Exception as e:
2896
+ self.log_message(f"⚠ Failed to load conversation history: {e}")
2897
+
2898
+ def _save_conversation_history(self):
2899
+ """Save conversation to disk"""
2900
+ try:
2901
+ self.ai_conversation_file.parent.mkdir(parents=True, exist_ok=True)
2902
+ with open(self.ai_conversation_file, 'w', encoding='utf-8') as f:
2903
+ json.dump({
2904
+ 'history': self.chat_history,
2905
+ 'files': self.attached_files,
2906
+ 'updated': datetime.now().isoformat()
2907
+ }, f, indent=2)
2908
+ except Exception as e:
2909
+ self.log_message(f"⚠ Failed to save conversation: {e}")
2910
+
2911
+ def _load_persisted_attachments(self):
2912
+ """Load attached files from AttachmentManager"""
2913
+ try:
2914
+ # Load files from current session
2915
+ files = self.attachment_manager.list_session_files()
2916
+
2917
+ # Populate attached_files for backward compatibility
2918
+ for file_meta in files:
2919
+ # Get full file data including content
2920
+ file_data = self.attachment_manager.get_file(file_meta['file_id'])
2921
+ if file_data:
2922
+ # Convert to old format for compatibility
2923
+ self.attached_files.append({
2924
+ 'path': file_data.get('original_path', ''),
2925
+ 'name': file_data.get('original_name', ''),
2926
+ 'content': file_data.get('content', ''),
2927
+ 'type': file_data.get('file_type', ''),
2928
+ 'size': file_data.get('size_chars', 0),
2929
+ 'attached_at': file_data.get('attached_at', ''),
2930
+ 'file_id': file_data.get('file_id', '') # Keep ID for reference
2931
+ })
2932
+
2933
+ if files:
2934
+ self.log_message(f"✓ Loaded {len(files)} attached files from session")
2935
+
2936
+ except Exception as e:
2937
+ self.log_message(f"⚠ Failed to load persisted attachments: {e}")
2938
+
2939
+ def _analyze_and_generate(self):
2940
+ """Analyze current project and generate prompts"""
2941
+ if not self.llm_client:
2942
+ self._add_chat_message(
2943
+ "system",
2944
+ "⚠ AI Assistant not available. Please configure API keys in Settings."
2945
+ )
2946
+ return
2947
+
2948
+ self._add_chat_message(
2949
+ "system",
2950
+ "🔍 Analyzing project and generating prompts...\n\n"
2951
+ "Gathering context from:\n"
2952
+ "• Current document\n"
2953
+ "• Translation memories\n"
2954
+ "• Termbases\n"
2955
+ "• Existing prompts"
2956
+ )
2957
+
2958
+ # Build context
2959
+ context = self._build_project_context()
2960
+
2961
+ # Create analysis prompt
2962
+ analysis_prompt = f"""Create a comprehensive translation prompt for this project and save it using the ACTION system.
2963
+
2964
+ PROJECT CONTEXT:
2965
+ {context}
2966
+
2967
+ Create a translation prompt. Output ONE complete ACTION block:
2968
+
2969
+ ACTION:create_prompt PARAMS:{{"name": "[Name]", "content": "[Prompt]", "folder": "Project Prompts", "description": "Auto-generated", "activate": true}}
2970
+
2971
+ Prompt must include:
2972
+
2973
+ # ROLE & EXPERTISE
2974
+ You are an expert [domain] translator ([source] → [target]) with 10+ years experience.
2975
+
2976
+ # DOCUMENT CONTEXT
2977
+ **Type:** [type]
2978
+ **Domain:** [domain]
2979
+ **Language pair:** [source] → [target]
2980
+ **Content:** [brief description]
2981
+ **Number of segments:** [count]
2982
+
2983
+ # KEY TERMINOLOGY
2984
+ | [Source] | [Target] | Notes |
2985
+ |----------|----------|-------|
2986
+ [Extract 20+ key terms from termbases/document]
2987
+
2988
+ # TRANSLATION CONSTRAINTS
2989
+ **MUST:**
2990
+ - Preserve all tags, markers, and placeholders exactly as in the source
2991
+ - Translate strictly one segment per line, preserving segmentation and order
2992
+ - Follow the KEY TERMINOLOGY glossary exactly for all mapped terms
2993
+ - If a segment is already in the target language, leave it unchanged
2994
+
2995
+ **MUST NOT:**
2996
+ - Add explanations, comments, footnotes, or translator's notes
2997
+ - Modify formatting, tags, numbering, brackets, or spacing
2998
+ - Merge or split segments
2999
+
3000
+ **CRITICAL:** Based on the language pair, include appropriate format localization rules:
3001
+
3002
+ ### NUMBERS, DATES & LOCALISATION
3003
+ - If translating FROM Dutch/French/German/Spanish/Italian TO English: Include number format conversion (comma decimal → period decimal, e.g., 718.592,01 → 718,592.01)
3004
+ - If translating FROM English TO Dutch/French/German/Spanish/Italian: Include number format conversion (period decimal → comma decimal)
3005
+ - Include date localization rules if relevant (e.g., Dutch month names → English: juni → June)
3006
+
3007
+ ### DOMAIN-SPECIFIC RULES
3008
+ - For LEGAL domain (Belgian): Include "Preserve 'Meester' + surname format for Belgian notaries"
3009
+ - For LEGAL domain: Include preservation of legal entity abbreviations (e.g., BV, NV, RPR)
3010
+ - For MEDICAL domain: Include anatomical term consistency
3011
+ - For TECHNICAL domain: Include measurement unit handling
3012
+
3013
+ # OUTPUT FORMAT
3014
+ Provide ONLY the translation, one segment per line, aligned 1:1 with the source lines.
3015
+
3016
+ Output complete ACTION."""
3017
+
3018
+ # Send to AI (in thread to avoid blocking UI)
3019
+ self._send_ai_request(analysis_prompt, is_analysis=True)
3020
+
3021
+ def _build_project_context(self) -> str:
3022
+ """Build context from current project"""
3023
+ context_parts = []
3024
+
3025
+ # Current document info
3026
+ if hasattr(self.parent_app, 'current_document_path'):
3027
+ doc_path = self.parent_app.current_document_path
3028
+ if doc_path:
3029
+ context_parts.append(f"**Document:** {Path(doc_path).name}")
3030
+
3031
+ # Language pair
3032
+ if hasattr(self.parent_app, 'current_project') and self.parent_app.current_project:
3033
+ project = self.parent_app.current_project
3034
+ if hasattr(project, 'source_language') and hasattr(project, 'target_language'):
3035
+ context_parts.append(f"**Language Pair:** {project.source_language} → {project.target_language}")
3036
+
3037
+ # Full document content
3038
+ if hasattr(self.parent_app, 'current_project') and self.parent_app.current_project:
3039
+ project = self.parent_app.current_project
3040
+ if hasattr(project, 'segments') and project.segments:
3041
+ total = len(project.segments)
3042
+ context_parts.append(f"\n**Project Size:** {total} segments")
3043
+
3044
+ # Try to get full document markdown (up to 50,000 chars for analysis)
3045
+ if self._cached_document_markdown:
3046
+ # Use cached markdown
3047
+ doc_content = self._cached_document_markdown[:50000]
3048
+ context_parts.append(f"\n**Full Document Content:**\n{doc_content}")
3049
+ else:
3050
+ # Fallback: Construct from segments (first 100 segments)
3051
+ context_parts.append(f"\n**Document Content (first 100 segments):**")
3052
+ for i, seg in enumerate(project.segments[:100], 1):
3053
+ context_parts.append(f"\n{i}. {seg.source}")
3054
+ if seg.target:
3055
+ context_parts.append(f" → {seg.target}")
3056
+
3057
+ # Attached files
3058
+ if self.attached_files:
3059
+ context_parts.append(f"\n**Attached Files ({len(self.attached_files)}):**")
3060
+ for file in self.attached_files:
3061
+ context_parts.append(f"- {file['name']}: {len(file.get('content', ''))} chars")
3062
+ # Show preview of file content
3063
+ if file.get('content'):
3064
+ preview = file['content'][:200].replace('\n', ' ')
3065
+ context_parts.append(f" Preview: {preview}...")
3066
+
3067
+ return "\n".join(context_parts) if context_parts else "No context available"
3068
+
3069
+ def _list_available_prompts(self) -> str:
3070
+ """List all prompts in library"""
3071
+ if not self.library.prompts:
3072
+ return "No prompts in library"
3073
+
3074
+ lines = []
3075
+ for path, data in list(self.library.prompts.items())[:20]: # First 20
3076
+ name = data.get('name', Path(path).stem)
3077
+ folder = Path(path).parent.name
3078
+ lines.append(f"- {folder}/{name}")
3079
+
3080
+ if len(self.library.prompts) > 20:
3081
+ lines.append(f"... and {len(self.library.prompts) - 20} more")
3082
+
3083
+ return "\n".join(lines)
3084
+
3085
+ def _attach_file(self):
3086
+ """Attach a file to the conversation"""
3087
+ file_path, _ = QFileDialog.getOpenFileName(
3088
+ None,
3089
+ "Attach File",
3090
+ "",
3091
+ "Documents (*.pdf *.docx *.txt *.md);;All Files (*.*)"
3092
+ )
3093
+ if not file_path:
3094
+ return
3095
+
3096
+ try:
3097
+ file_path_obj = Path(file_path)
3098
+
3099
+ # Read file content based on type
3100
+ content = ""
3101
+ file_type = file_path_obj.suffix.lower()
3102
+ conversion_note = ""
3103
+
3104
+ if file_type == '.txt' or file_type == '.md':
3105
+ with open(file_path, 'r', encoding='utf-8') as f:
3106
+ content = f.read()
3107
+ elif file_type in ['.pdf', '.docx', '.pptx', '.xlsx']:
3108
+ # Use markitdown for document conversion
3109
+ try:
3110
+ from markitdown import MarkItDown
3111
+ md = MarkItDown()
3112
+ result = md.convert(file_path)
3113
+ content = result.text_content
3114
+ conversion_note = f" (converted to markdown: {len(content):,} chars)"
3115
+ except ImportError:
3116
+ content = f"[{file_type.upper()} file: {file_path_obj.name}]\n(markitdown not installed - run: pip install markitdown)"
3117
+ conversion_note = " (conversion unavailable)"
3118
+ except Exception as e:
3119
+ content = f"[{file_type.upper()} file: {file_path_obj.name}]\n(Conversion error: {e})"
3120
+ conversion_note = f" (conversion failed: {e})"
3121
+ else:
3122
+ try:
3123
+ with open(file_path, 'r', encoding='utf-8') as f:
3124
+ content = f.read()
3125
+ except:
3126
+ content = f"[Binary file: {file_path_obj.name}]"
3127
+
3128
+ # Save to AttachmentManager (persistent storage)
3129
+ file_id = self.attachment_manager.attach_file(
3130
+ original_path=str(file_path),
3131
+ markdown_content=content,
3132
+ original_name=file_path_obj.name,
3133
+ conversation_id=None # Optional conversation tracking
3134
+ )
3135
+
3136
+ if file_id:
3137
+ # Add to attached files for backward compatibility
3138
+ file_data = {
3139
+ 'path': str(file_path),
3140
+ 'name': file_path_obj.name,
3141
+ 'content': content,
3142
+ 'type': file_type,
3143
+ 'size': len(content),
3144
+ 'attached_at': datetime.now().isoformat(),
3145
+ 'file_id': file_id # Store ID for later reference
3146
+ }
3147
+ self.attached_files.append(file_data)
3148
+
3149
+ # Update UI
3150
+ self._update_context_sidebar()
3151
+
3152
+ # Add message
3153
+ self._add_chat_message(
3154
+ "system",
3155
+ f"📎 File attached: **{file_path_obj.name}**\n"
3156
+ f"Type: {file_type}, Size: {len(content):,} chars{conversion_note}\n\n"
3157
+ f"You can now ask questions about this file."
3158
+ )
3159
+
3160
+ self._save_conversation_history()
3161
+ else:
3162
+ QMessageBox.warning(None, "Attachment Error", "Failed to save attachment to disk.")
3163
+
3164
+ except Exception as e:
3165
+ QMessageBox.warning(None, "Attachment Error", f"Failed to attach file:\n{e}")
3166
+
3167
+ def _update_context_sidebar(self):
3168
+ """Update the context sidebar with current state"""
3169
+ # Update current document display
3170
+ self._update_current_document_display()
3171
+
3172
+ # Update attached files list
3173
+ if hasattr(self, 'attached_files_list_layout'):
3174
+ self._refresh_attached_files_list()
3175
+
3176
+ def _update_current_document_display(self):
3177
+ """Update the current document section in the sidebar"""
3178
+ if not hasattr(self, 'context_current_doc'):
3179
+ return
3180
+
3181
+ # Get document info from parent app
3182
+ doc_info = "No document loaded"
3183
+
3184
+ if hasattr(self.parent_app, 'current_project') and self.parent_app.current_project:
3185
+ project = self.parent_app.current_project
3186
+ # Get project name
3187
+ project_name = getattr(project, 'name', 'Unnamed Project')
3188
+
3189
+ # Get document info
3190
+ if hasattr(self.parent_app, 'current_document_path') and self.parent_app.current_document_path:
3191
+ doc_path = Path(self.parent_app.current_document_path)
3192
+ doc_info = f"{project_name}\n{doc_path.name}"
3193
+ elif hasattr(project, 'source_file') and project.source_file:
3194
+ doc_path = Path(project.source_file)
3195
+ doc_info = f"{project_name}\n{doc_path.name}"
3196
+ else:
3197
+ doc_info = f"{project_name}\nNo document"
3198
+
3199
+ # Update the label (find the description label in the section)
3200
+ # The section has a QVBoxLayout with [title_label, desc_label]
3201
+ layout = self.context_current_doc.layout()
3202
+ if layout and layout.count() >= 2:
3203
+ desc_label = layout.itemAt(1).widget()
3204
+ if isinstance(desc_label, QLabel):
3205
+ desc_label.setText(doc_info)
3206
+
3207
+ def _get_document_content_preview(self, max_chars=3000):
3208
+ """
3209
+ Get a preview of the current document content.
3210
+
3211
+ Tries multiple methods to access document content:
3212
+ 1. From parent_app segments (if available)
3213
+ 2. From project source_segments or target_segments
3214
+ 3. Direct file read if needed
3215
+
3216
+ Returns:
3217
+ String with document preview (first max_chars characters) or None
3218
+ """
3219
+ try:
3220
+ # Method 1: Try to get segments from parent app
3221
+ if hasattr(self.parent_app, 'segments') and self.parent_app.segments:
3222
+ segments = self.parent_app.segments
3223
+ # Combine segment source text
3224
+ lines = []
3225
+ for seg in segments[:50]: # First 50 segments
3226
+ if hasattr(seg, 'source'):
3227
+ lines.append(seg.source)
3228
+ elif isinstance(seg, dict) and 'source' in seg:
3229
+ lines.append(seg['source'])
3230
+
3231
+ if lines:
3232
+ content = '\n'.join(lines)
3233
+ return content[:max_chars]
3234
+
3235
+ # Method 2: Try to get from current project's segments
3236
+ if hasattr(self.parent_app, 'current_project') and self.parent_app.current_project:
3237
+ project = self.parent_app.current_project
3238
+
3239
+ # Check for source_segments attribute
3240
+ if hasattr(project, 'source_segments') and project.source_segments:
3241
+ lines = []
3242
+ for seg in project.source_segments[:50]:
3243
+ if isinstance(seg, str):
3244
+ lines.append(seg)
3245
+ elif hasattr(seg, 'text'):
3246
+ lines.append(seg.text)
3247
+ elif isinstance(seg, dict) and 'text' in seg:
3248
+ lines.append(seg['text'])
3249
+
3250
+ if lines:
3251
+ content = '\n'.join(lines)
3252
+ return content[:max_chars]
3253
+
3254
+ # Method 3: Check if we have a cached markdown conversion
3255
+ if hasattr(self, '_cached_document_markdown') and self._cached_document_markdown:
3256
+ return self._cached_document_markdown[:max_chars]
3257
+
3258
+ # Method 4: Try converting the source document file with markitdown
3259
+ if hasattr(self.parent_app, 'current_document_path') and self.parent_app.current_document_path:
3260
+ doc_path = Path(self.parent_app.current_document_path)
3261
+ if doc_path.exists():
3262
+ # Try to convert with markitdown
3263
+ converted = self._convert_document_to_markdown(doc_path)
3264
+ if converted:
3265
+ # Cache for future use
3266
+ self._cached_document_markdown = converted
3267
+ # Also save to disk for user access
3268
+ self._save_document_markdown(doc_path, converted)
3269
+ return converted[:max_chars]
3270
+
3271
+ return None
3272
+
3273
+ except Exception as e:
3274
+ self.log_message(f"⚠ Error getting document content preview: {e}")
3275
+ return None
3276
+
3277
+ def _convert_document_to_markdown(self, file_path: Path) -> str:
3278
+ """
3279
+ Convert a document to markdown using markitdown.
3280
+
3281
+ Args:
3282
+ file_path: Path to the document file
3283
+
3284
+ Returns:
3285
+ Markdown content or None if conversion fails
3286
+ """
3287
+ try:
3288
+ from markitdown import MarkItDown
3289
+
3290
+ md = MarkItDown()
3291
+ result = md.convert(str(file_path))
3292
+
3293
+ if result and hasattr(result, 'text_content'):
3294
+ return result.text_content
3295
+ elif isinstance(result, str):
3296
+ return result
3297
+
3298
+ return None
3299
+
3300
+ except Exception as e:
3301
+ self.log_message(f"⚠ Error converting document to markdown: {e}")
3302
+ return None
3303
+
3304
+ def _save_document_markdown(self, original_path: Path, markdown_content: str):
3305
+ """
3306
+ Save the markdown conversion of the current document.
3307
+
3308
+ Saves to: user_data_private/AI_Assistant/current_document/
3309
+
3310
+ Args:
3311
+ original_path: Original document file path
3312
+ markdown_content: Converted markdown content
3313
+ """
3314
+ try:
3315
+ # Create directory for current document markdown
3316
+ doc_dir = self.user_data_path / "AI_Assistant" / "current_document"
3317
+ doc_dir.mkdir(parents=True, exist_ok=True)
3318
+
3319
+ # Create filename based on original
3320
+ md_filename = original_path.stem + ".md"
3321
+ md_path = doc_dir / md_filename
3322
+
3323
+ # Save markdown content
3324
+ md_path.write_text(markdown_content, encoding='utf-8')
3325
+
3326
+ # Save metadata
3327
+ metadata = {
3328
+ "original_file": str(original_path),
3329
+ "original_name": original_path.name,
3330
+ "converted_at": datetime.now().isoformat(),
3331
+ "markdown_file": str(md_path),
3332
+ "size_chars": len(markdown_content)
3333
+ }
3334
+
3335
+ meta_path = doc_dir / (original_path.stem + ".meta.json")
3336
+ meta_path.write_text(json.dumps(metadata, indent=2), encoding='utf-8')
3337
+
3338
+ self.log_message(f"✓ Saved document markdown: {md_filename}")
3339
+
3340
+ except Exception as e:
3341
+ self.log_message(f"⚠ Error saving document markdown: {e}")
3342
+
3343
+ def generate_markdown_for_current_document(self) -> bool:
3344
+ """
3345
+ Public method to generate markdown for the current document.
3346
+ Called by main app when auto-markdown is enabled.
3347
+
3348
+ Returns:
3349
+ True if markdown was generated successfully, False otherwise
3350
+ """
3351
+ try:
3352
+ # Check if we have a current document
3353
+ if not hasattr(self.parent_app, 'current_document_path') or not self.parent_app.current_document_path:
3354
+ return False
3355
+
3356
+ doc_path = Path(self.parent_app.current_document_path)
3357
+ if not doc_path.exists():
3358
+ return False
3359
+
3360
+ # Convert to markdown
3361
+ markdown_content = self._convert_document_to_markdown(doc_path)
3362
+ if not markdown_content:
3363
+ return False
3364
+
3365
+ # Save markdown and metadata
3366
+ self._save_document_markdown(doc_path, markdown_content)
3367
+
3368
+ # Cache for session
3369
+ self._cached_document_markdown = markdown_content
3370
+
3371
+ self.log_message(f"✓ Generated markdown for {doc_path.name}")
3372
+ return True
3373
+
3374
+ except Exception as e:
3375
+ self.log_message(f"⚠ Error generating markdown: {e}")
3376
+ return False
3377
+
3378
+ def _get_segment_info(self) -> str:
3379
+ """
3380
+ Get structured segment information for AI context.
3381
+
3382
+ Returns:
3383
+ Formatted string with segment count and ALL segments, or None if no segments available
3384
+ """
3385
+ try:
3386
+ segments = None
3387
+
3388
+ # Try to get segments from parent app (preferred - most current)
3389
+ if hasattr(self.parent_app, 'current_project') and self.parent_app.current_project:
3390
+ project = self.parent_app.current_project
3391
+ if hasattr(project, 'segments') and project.segments:
3392
+ segments = project.segments
3393
+
3394
+ if not segments:
3395
+ return None
3396
+
3397
+ total_count = len(segments)
3398
+
3399
+ # Build segment overview
3400
+ lines = []
3401
+ lines.append(f"- Total segments: {total_count}")
3402
+
3403
+ # Add statistics
3404
+ translated_count = sum(1 for seg in segments if seg.target and seg.target.strip())
3405
+ lines.append(f"- Translated: {translated_count}/{total_count}")
3406
+
3407
+ # Include ALL segments (up to 500 to stay within token limits)
3408
+ # This allows the AI to search and answer questions about the full document
3409
+ max_segments = min(500, total_count)
3410
+ lines.append(f"\nDocument segments ({max_segments} of {total_count}):")
3411
+
3412
+ for seg in segments[:max_segments]:
3413
+ # Use full source text (not truncated) so AI can search for terms
3414
+ source_text = seg.source.replace('\n', ' ') # Normalize newlines
3415
+ target_text = ""
3416
+ if seg.target:
3417
+ target_text = seg.target.replace('\n', ' ')
3418
+
3419
+ lines.append(f"\nSegment {seg.id}: Source:{source_text}; Target:{target_text}; Status:{seg.status}")
3420
+
3421
+ if total_count > max_segments:
3422
+ lines.append(f"\n... and {total_count - max_segments} more segments (not shown)")
3423
+
3424
+ return "\n".join(lines)
3425
+
3426
+ except Exception as e:
3427
+ self.log_message(f"⚠ Error getting segment info: {e}")
3428
+ return None
3429
+
3430
+ def _send_chat_message(self):
3431
+ """Send a chat message to AI"""
3432
+ message = self.chat_input.toPlainText().strip()
3433
+ if not message:
3434
+ return
3435
+
3436
+ if not self.llm_client:
3437
+ self._add_chat_message(
3438
+ "system",
3439
+ "⚠ AI Assistant not available. Please configure API keys in Settings."
3440
+ )
3441
+ return
3442
+
3443
+ # Add user message
3444
+ self._add_chat_message("user", message)
3445
+ self.chat_input.clear()
3446
+
3447
+ # Build context for AI
3448
+ context = self._build_ai_context(message)
3449
+
3450
+ # Send to AI
3451
+ self._send_ai_request(context)
3452
+
3453
+ def _build_ai_context(self, user_message: str) -> str:
3454
+ """Build full context for AI request"""
3455
+ parts = []
3456
+
3457
+ # System context
3458
+ parts.append("You are an AI assistant for Supervertaler, a professional translation tool.")
3459
+ parts.append("\nAVAILABLE RESOURCES:")
3460
+
3461
+ # Current document/project info
3462
+ if hasattr(self.parent_app, 'current_project') and self.parent_app.current_project:
3463
+ project = self.parent_app.current_project
3464
+ project_name = getattr(project, 'name', 'Unnamed Project')
3465
+ parts.append(f"- Current Project: {project_name}")
3466
+
3467
+ if hasattr(self.parent_app, 'current_document_path') and self.parent_app.current_document_path:
3468
+ doc_path = Path(self.parent_app.current_document_path)
3469
+ parts.append(f"- Current Document: {doc_path.name}")
3470
+ elif hasattr(project, 'source_file') and project.source_file:
3471
+ doc_path = Path(project.source_file)
3472
+ parts.append(f"- Current Document: {doc_path.name}")
3473
+
3474
+ # Add language pair info if available
3475
+ if hasattr(project, 'source_lang') and hasattr(project, 'target_lang'):
3476
+ parts.append(f"- Language Pair: {project.source_lang} → {project.target_lang}")
3477
+
3478
+ # Add segment information if available
3479
+ segment_info = self._get_segment_info()
3480
+ if segment_info:
3481
+ parts.append(f"\nDOCUMENT SEGMENTS:")
3482
+ parts.append(segment_info)
3483
+
3484
+ # Add document content preview if available (only if no segments)
3485
+ elif not segment_info:
3486
+ doc_content = self._get_document_content_preview()
3487
+ if doc_content:
3488
+ parts.append(f"\nCURRENT DOCUMENT CONTENT (first 3000 characters):")
3489
+ parts.append(doc_content)
3490
+
3491
+ parts.append(f"- Prompt Library: {len(self.library.prompts)} prompts")
3492
+ parts.append(f"- Attached Files: {len(self.attached_files)} files")
3493
+
3494
+ # Add action system instructions (Phase 2)
3495
+ parts.append(self.ai_action_system.get_system_prompt_addition())
3496
+
3497
+ # Recent conversation (last 5 messages)
3498
+ if len(self.chat_history) > 1:
3499
+ parts.append("\nRECENT CONVERSATION:")
3500
+ for msg in self.chat_history[-5:]:
3501
+ if msg['role'] in ['user', 'assistant']:
3502
+ parts.append(f"{msg['role'].upper()}: {msg['content'][:200]}")
3503
+
3504
+ # Attached files content
3505
+ if self.attached_files:
3506
+ parts.append("\nATTACHED FILES CONTENT:")
3507
+ for file in self.attached_files[-3:]: # Last 3 files
3508
+ parts.append(f"\n--- {file['name']} ---")
3509
+ parts.append(file['content'][:2000]) # First 2000 chars
3510
+
3511
+ # User's current message
3512
+ parts.append(f"\nUSER QUESTION:\n{user_message}")
3513
+
3514
+ return "\n".join(parts)
3515
+
3516
+ def refresh_llm_client(self):
3517
+ """Refresh LLM client when settings change"""
3518
+ self._init_llm_client()
3519
+
3520
+ def _send_ai_request(self, prompt: str, is_analysis: bool = False):
3521
+ """Send request to AI and handle response"""
3522
+ # Refresh LLM client to get latest provider settings
3523
+ self._init_llm_client()
3524
+
3525
+ if not self.llm_client:
3526
+ self._add_chat_message(
3527
+ "system",
3528
+ "⚠ AI Assistant not available. Please configure API keys in Settings."
3529
+ )
3530
+ return
3531
+
3532
+ try:
3533
+ # Log the request
3534
+ self.log_message(f"[AI Assistant] Sending request to {self.llm_client.provider} ({self.llm_client.model})")
3535
+ self.log_message(f"[AI Assistant] Prompt length: {len(prompt)} characters")
3536
+
3537
+ # Show thinking message (don't save to history)
3538
+ self._add_chat_message("system", "🤔 Thinking...", save=False)
3539
+
3540
+ # Force UI update
3541
+ if hasattr(self, 'chat_display'):
3542
+ from PyQt6.QtWidgets import QApplication
3543
+ QApplication.processEvents()
3544
+
3545
+ # Call LLM using translate method with custom prompt
3546
+ # The translate method accepts a custom_prompt parameter that we can use for any text generation
3547
+ self.log_message("[AI Assistant] Calling LLM translate method...")
3548
+ response = self.llm_client.translate(
3549
+ text="", # Empty text since we're using custom_prompt
3550
+ source_lang="en",
3551
+ target_lang="en",
3552
+ custom_prompt=prompt
3553
+ )
3554
+
3555
+ # Log the response
3556
+ self.log_message(f"[AI Assistant] Received response: {len(response) if response else 0} characters")
3557
+ if response:
3558
+ self.log_message(f"[AI Assistant] Response preview: {response[:200]}...")
3559
+
3560
+ # Clear the thinking message by clearing and reloading history
3561
+ self._reload_chat_display()
3562
+
3563
+ # Check if we got a valid response
3564
+ if response and response.strip():
3565
+ self.log_message("[AI Assistant] Processing response with action system...")
3566
+ # Parse and execute actions (Phase 2)
3567
+ cleaned_response, action_results = self.ai_action_system.parse_and_execute(response)
3568
+
3569
+ self.log_message(f"[AI Assistant] Cleaned response: {len(cleaned_response)} characters")
3570
+ self.log_message(f"[AI Assistant] Actions executed: {len(action_results)}")
3571
+
3572
+ # Add the cleaned response (without ACTION blocks) - only if non-empty
3573
+ if cleaned_response and cleaned_response.strip():
3574
+ self._add_chat_message("assistant", cleaned_response)
3575
+
3576
+ # If actions were executed, show results
3577
+ if action_results:
3578
+ formatted_results = self.ai_action_system.format_action_results(action_results)
3579
+ self._add_chat_message("system", formatted_results)
3580
+ else:
3581
+ # No actions found - show warning with first 500 chars of response for debugging
3582
+ if not (cleaned_response and cleaned_response.strip()):
3583
+ self.log_message(f"[AI Assistant] ⚠ No actions found in response. First 500 chars: {response[:500]}")
3584
+ self._add_chat_message("system", "⚠ AI responded but no actions were found. Check logs for details.")
3585
+
3586
+ # Reload prompt library if any prompts were modified
3587
+ if action_results and any(r['action'] in ['create_prompt', 'update_prompt', 'delete_prompt', 'activate_prompt']
3588
+ for r in action_results if r['success']):
3589
+ self.log_message("[AI Assistant] Reloading prompt library due to prompt modifications...")
3590
+ self.library.load_all_prompts()
3591
+ # Refresh tree widget if it exists
3592
+ if hasattr(self, 'tree_widget') and self.tree_widget:
3593
+ self._refresh_tree()
3594
+ # Refresh active prompt display
3595
+ if hasattr(self, '_update_active_prompt_display'):
3596
+ self._update_active_prompt_display()
3597
+
3598
+ self.log_message("[AI Assistant] ✓ Request completed successfully")
3599
+ else:
3600
+ self.log_message("[AI Assistant] ⚠ Received empty response from AI")
3601
+ self._add_chat_message(
3602
+ "system",
3603
+ "⚠ Received empty response from AI. Please try again."
3604
+ )
3605
+
3606
+ except Exception as e:
3607
+ # Clear the thinking message
3608
+ self._reload_chat_display()
3609
+
3610
+ # Log the full error
3611
+ import traceback
3612
+ error_details = traceback.format_exc()
3613
+ self.log_message(f"[AI Assistant] ❌ ERROR: {error_details}")
3614
+ print(f"AI Assistant Error:\n{error_details}") # Also print to console
3615
+
3616
+ self._add_chat_message(
3617
+ "system",
3618
+ f"⚠ Error communicating with AI: {str(e)}\n\nCheck the log for details."
3619
+ )
3620
+
3621
+ def _reload_chat_display(self):
3622
+ """Reload chat display from history"""
3623
+ if not hasattr(self, 'chat_display'):
3624
+ return
3625
+
3626
+ # Clear display
3627
+ self.chat_display.clear()
3628
+
3629
+ # Reload all messages from history
3630
+ for msg in self.chat_history:
3631
+ self._add_chat_message(msg['role'], msg['content'], save=False)
3632
+
3633
+ def _clear_chat(self):
3634
+ """Clear chat history and display"""
3635
+ reply = QMessageBox.question(
3636
+ None,
3637
+ "Clear Chat History",
3638
+ "Are you sure you want to clear the entire conversation history?\n\nThis cannot be undone.",
3639
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
3640
+ QMessageBox.StandardButton.No
3641
+ )
3642
+
3643
+ if reply == QMessageBox.StandardButton.Yes:
3644
+ # Clear history
3645
+ self.chat_history = []
3646
+ self.attached_files = []
3647
+
3648
+ # Save empty history
3649
+ self._save_conversation_history()
3650
+
3651
+ # Clear display
3652
+ if hasattr(self, 'chat_display'):
3653
+ self.chat_display.clear()
3654
+
3655
+ # Update context sidebar
3656
+ self._update_context_sidebar()
3657
+
3658
+ # Show confirmation
3659
+ self._add_chat_message(
3660
+ "system",
3661
+ "✨ Chat cleared! Start a new conversation.",
3662
+ save=False
3663
+ )
3664
+
3665
+ def _show_chat_context_menu(self, position):
3666
+ """Show context menu for chat messages to allow copying"""
3667
+ item = self.chat_display.itemAt(position)
3668
+ if item is None:
3669
+ return
3670
+
3671
+ # Get message data
3672
+ message_data = item.data(Qt.ItemDataRole.UserRole)
3673
+ if not message_data:
3674
+ return
3675
+
3676
+ message_text = message_data.get('content', '')
3677
+
3678
+ # Create context menu
3679
+ menu = QMenu()
3680
+
3681
+ copy_action = menu.addAction("📋 Copy Message")
3682
+ copy_action.triggered.connect(lambda: self._copy_message_to_clipboard(message_text))
3683
+
3684
+ # Show menu at cursor position
3685
+ menu.exec(self.chat_display.mapToGlobal(position))
3686
+
3687
+ def _copy_message_to_clipboard(self, text: str):
3688
+ """Copy message text to clipboard"""
3689
+ from PyQt6.QtWidgets import QApplication
3690
+ clipboard = QApplication.clipboard()
3691
+ clipboard.setText(text)
3692
+
3693
+ # Show brief confirmation
3694
+ self._add_chat_message(
3695
+ "system",
3696
+ "✓ Message copied to clipboard",
3697
+ save=False
3698
+ )
3699
+
3700
+ def _add_chat_message(self, role: str, message: str, save: bool = True):
3701
+ """Add a message to the chat display"""
3702
+ # Save to history
3703
+ if save:
3704
+ self.chat_history.append({
3705
+ 'role': role,
3706
+ 'content': message,
3707
+ 'timestamp': datetime.now().isoformat()
3708
+ })
3709
+ self._save_conversation_history()
3710
+
3711
+ # Update UI
3712
+ if not hasattr(self, 'chat_display'):
3713
+ return
3714
+
3715
+ # Create list item with message data
3716
+ item = QListWidgetItem()
3717
+ item.setData(Qt.ItemDataRole.UserRole, {
3718
+ 'role': role,
3719
+ 'content': message,
3720
+ 'timestamp': datetime.now().isoformat()
3721
+ })
3722
+
3723
+ # Add to list
3724
+ self.chat_display.addItem(item)
3725
+
3726
+ # Scroll to bottom
3727
+ self.chat_display.scrollToBottom()