supervertaler 1.9.153__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.

Potentially problematic release.


This version of supervertaler might be problematic. Click here for more details.

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