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.
- Supervertaler.py +47886 -0
- modules/__init__.py +10 -0
- modules/ai_actions.py +964 -0
- modules/ai_attachment_manager.py +343 -0
- modules/ai_file_viewer_dialog.py +210 -0
- modules/autofingers_engine.py +466 -0
- modules/cafetran_docx_handler.py +379 -0
- modules/config_manager.py +469 -0
- modules/database_manager.py +1878 -0
- modules/database_migrations.py +417 -0
- modules/dejavurtf_handler.py +779 -0
- modules/document_analyzer.py +427 -0
- modules/docx_handler.py +689 -0
- modules/encoding_repair.py +319 -0
- modules/encoding_repair_Qt.py +393 -0
- modules/encoding_repair_ui.py +481 -0
- modules/feature_manager.py +350 -0
- modules/figure_context_manager.py +340 -0
- modules/file_dialog_helper.py +148 -0
- modules/find_replace.py +164 -0
- modules/find_replace_qt.py +457 -0
- modules/glossary_manager.py +433 -0
- modules/image_extractor.py +188 -0
- modules/keyboard_shortcuts_widget.py +571 -0
- modules/llm_clients.py +1211 -0
- modules/llm_leaderboard.py +737 -0
- modules/llm_superbench_ui.py +1401 -0
- modules/local_llm_setup.py +1104 -0
- modules/model_update_dialog.py +381 -0
- modules/model_version_checker.py +373 -0
- modules/mqxliff_handler.py +638 -0
- modules/non_translatables_manager.py +743 -0
- modules/pdf_rescue_Qt.py +1822 -0
- modules/pdf_rescue_tkinter.py +909 -0
- modules/phrase_docx_handler.py +516 -0
- modules/project_home_panel.py +209 -0
- modules/prompt_assistant.py +357 -0
- modules/prompt_library.py +689 -0
- modules/prompt_library_migration.py +447 -0
- modules/quick_access_sidebar.py +282 -0
- modules/ribbon_widget.py +597 -0
- modules/sdlppx_handler.py +874 -0
- modules/setup_wizard.py +353 -0
- modules/shortcut_manager.py +932 -0
- modules/simple_segmenter.py +128 -0
- modules/spellcheck_manager.py +727 -0
- modules/statuses.py +207 -0
- modules/style_guide_manager.py +315 -0
- modules/superbench_ui.py +1319 -0
- modules/superbrowser.py +329 -0
- modules/supercleaner.py +600 -0
- modules/supercleaner_ui.py +444 -0
- modules/superdocs.py +19 -0
- modules/superdocs_viewer_qt.py +382 -0
- modules/superlookup.py +252 -0
- modules/tag_cleaner.py +260 -0
- modules/tag_manager.py +333 -0
- modules/term_extractor.py +270 -0
- modules/termbase_entry_editor.py +842 -0
- modules/termbase_import_export.py +488 -0
- modules/termbase_manager.py +1060 -0
- modules/termview_widget.py +1172 -0
- modules/theme_manager.py +499 -0
- modules/tm_editor_dialog.py +99 -0
- modules/tm_manager_qt.py +1280 -0
- modules/tm_metadata_manager.py +545 -0
- modules/tmx_editor.py +1461 -0
- modules/tmx_editor_qt.py +2784 -0
- modules/tmx_generator.py +284 -0
- modules/tracked_changes.py +900 -0
- modules/trados_docx_handler.py +430 -0
- modules/translation_memory.py +715 -0
- modules/translation_results_panel.py +2134 -0
- modules/translation_services.py +282 -0
- modules/unified_prompt_library.py +659 -0
- modules/unified_prompt_manager_qt.py +3951 -0
- modules/voice_commands.py +920 -0
- modules/voice_dictation.py +477 -0
- modules/voice_dictation_lite.py +249 -0
- supervertaler-1.9.153.dist-info/METADATA +896 -0
- supervertaler-1.9.153.dist-info/RECORD +85 -0
- supervertaler-1.9.153.dist-info/WHEEL +5 -0
- supervertaler-1.9.153.dist-info/entry_points.txt +2 -0
- supervertaler-1.9.153.dist-info/licenses/LICENSE +21 -0
- 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('&', '&').replace('<', '<').replace('>', '>')
|
|
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()
|