thonny-codemate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thonny_codemate-0.1.0.dist-info/METADATA +307 -0
- thonny_codemate-0.1.0.dist-info/RECORD +27 -0
- thonny_codemate-0.1.0.dist-info/WHEEL +5 -0
- thonny_codemate-0.1.0.dist-info/licenses/LICENSE +21 -0
- thonny_codemate-0.1.0.dist-info/top_level.txt +1 -0
- thonnycontrib/__init__.py +1 -0
- thonnycontrib/thonny_codemate/__init__.py +397 -0
- thonnycontrib/thonny_codemate/api.py +154 -0
- thonnycontrib/thonny_codemate/context_manager.py +296 -0
- thonnycontrib/thonny_codemate/external_providers.py +714 -0
- thonnycontrib/thonny_codemate/i18n.py +506 -0
- thonnycontrib/thonny_codemate/llm_client.py +841 -0
- thonnycontrib/thonny_codemate/message_virtualization.py +136 -0
- thonnycontrib/thonny_codemate/model_manager.py +515 -0
- thonnycontrib/thonny_codemate/performance_monitor.py +141 -0
- thonnycontrib/thonny_codemate/prompts.py +102 -0
- thonnycontrib/thonny_codemate/ui/__init__.py +1 -0
- thonnycontrib/thonny_codemate/ui/chat_view.py +687 -0
- thonnycontrib/thonny_codemate/ui/chat_view_html.py +1299 -0
- thonnycontrib/thonny_codemate/ui/custom_prompt_dialog.py +175 -0
- thonnycontrib/thonny_codemate/ui/markdown_renderer.py +484 -0
- thonnycontrib/thonny_codemate/ui/model_download_dialog.py +355 -0
- thonnycontrib/thonny_codemate/ui/settings_dialog.py +1218 -0
- thonnycontrib/thonny_codemate/utils/__init__.py +25 -0
- thonnycontrib/thonny_codemate/utils/constants.py +138 -0
- thonnycontrib/thonny_codemate/utils/error_messages.py +92 -0
- thonnycontrib/thonny_codemate/utils/unified_error_handler.py +310 -0
@@ -0,0 +1,1299 @@
|
|
1
|
+
"""
|
2
|
+
LLMチャットビュー(HTML版)
|
3
|
+
tkinterwebを使用してMarkdown表示と対話機能を提供
|
4
|
+
"""
|
5
|
+
import tkinter as tk
|
6
|
+
from tkinter import ttk, messagebox
|
7
|
+
import threading
|
8
|
+
import queue
|
9
|
+
import logging
|
10
|
+
import time
|
11
|
+
import json
|
12
|
+
import traceback
|
13
|
+
from pathlib import Path
|
14
|
+
from typing import Optional, List, Tuple
|
15
|
+
|
16
|
+
try:
|
17
|
+
from tkinterweb import HtmlFrame
|
18
|
+
except ImportError:
|
19
|
+
HtmlFrame = None
|
20
|
+
|
21
|
+
from thonny import get_workbench
|
22
|
+
|
23
|
+
# 安全なロガーを使用
|
24
|
+
try:
|
25
|
+
from .. import get_safe_logger
|
26
|
+
logger = get_safe_logger(__name__)
|
27
|
+
except ImportError:
|
28
|
+
logger = logging.getLogger(__name__)
|
29
|
+
logger.addHandler(logging.NullHandler())
|
30
|
+
|
31
|
+
from .markdown_renderer import MarkdownRenderer
|
32
|
+
from ..i18n import tr
|
33
|
+
|
34
|
+
# パフォーマンスモニタリングを試す(オプショナル)
|
35
|
+
try:
|
36
|
+
from ..performance_monitor import measure_performance, Timer
|
37
|
+
except ImportError:
|
38
|
+
# パフォーマンスモニタリングが利用できない場合はダミー実装
|
39
|
+
def measure_performance(operation=None):
|
40
|
+
def decorator(func):
|
41
|
+
return func
|
42
|
+
return decorator
|
43
|
+
|
44
|
+
class Timer:
|
45
|
+
def __init__(self, operation):
|
46
|
+
pass
|
47
|
+
def __enter__(self):
|
48
|
+
return self
|
49
|
+
def __exit__(self, *args):
|
50
|
+
pass
|
51
|
+
|
52
|
+
|
53
|
+
class LLMChatViewHTML(ttk.Frame):
|
54
|
+
"""
|
55
|
+
HTMLベースのLLMチャットインターフェース
|
56
|
+
Markdownレンダリングと対話機能を提供
|
57
|
+
"""
|
58
|
+
|
59
|
+
def __init__(self, master):
|
60
|
+
super().__init__(master)
|
61
|
+
|
62
|
+
# tkinterwebが利用可能かチェック
|
63
|
+
if HtmlFrame is None:
|
64
|
+
self._show_fallback_ui()
|
65
|
+
return
|
66
|
+
|
67
|
+
self.llm_client = None
|
68
|
+
self.markdown_renderer = MarkdownRenderer()
|
69
|
+
self.messages: List[Tuple[str, str]] = [] # [(sender, text), ...]
|
70
|
+
|
71
|
+
# メッセージキュー(スレッド間通信用)
|
72
|
+
self.message_queue = queue.Queue()
|
73
|
+
self._processing = False
|
74
|
+
self._stop_generation = False
|
75
|
+
self._first_token_received = False # 最初のトークンを受け取ったか
|
76
|
+
self._generating_animation_id = None # アニメーションのafter ID
|
77
|
+
|
78
|
+
# スレッドセーフティのためのロック
|
79
|
+
self._message_lock = threading.Lock()
|
80
|
+
self._current_message = "" # ストリーミング中のメッセージ(ロックで保護)
|
81
|
+
|
82
|
+
# HTMLが完全に読み込まれたかを追跡
|
83
|
+
self._html_ready = False
|
84
|
+
|
85
|
+
self._init_ui()
|
86
|
+
self._init_llm()
|
87
|
+
|
88
|
+
# 定期的にキューをチェック
|
89
|
+
self._queue_check_id = self.after(100, self._process_queue)
|
90
|
+
|
91
|
+
# ウィンドウ閉じるイベントをバインド
|
92
|
+
self.bind("<Destroy>", self._on_destroy)
|
93
|
+
|
94
|
+
# 最後の更新時刻(レート制限用)
|
95
|
+
self._last_update_time = 0
|
96
|
+
self._update_pending = False
|
97
|
+
|
98
|
+
# ストリーミングメッセージIDを生成
|
99
|
+
self._current_message_id = None
|
100
|
+
|
101
|
+
# 一時ファイルのパス
|
102
|
+
import tempfile
|
103
|
+
self._temp_dir = tempfile.mkdtemp(prefix="thonny_llm_")
|
104
|
+
self._current_html_path = None
|
105
|
+
|
106
|
+
# 会話履歴を読み込む
|
107
|
+
self._load_chat_history()
|
108
|
+
|
109
|
+
def _show_fallback_ui(self):
|
110
|
+
"""tkinterwebが利用できない場合のフォールバックUI"""
|
111
|
+
self.columnconfigure(0, weight=1)
|
112
|
+
self.rowconfigure(0, weight=1)
|
113
|
+
|
114
|
+
frame = ttk.Frame(self)
|
115
|
+
frame.grid(row=0, column=0, padx=20, pady=20)
|
116
|
+
|
117
|
+
ttk.Label(
|
118
|
+
frame,
|
119
|
+
text=tr("tkinterweb is not installed"),
|
120
|
+
font=("", 12, "bold")
|
121
|
+
).pack(pady=10)
|
122
|
+
|
123
|
+
ttk.Label(
|
124
|
+
frame,
|
125
|
+
text=tr("To enable Markdown rendering and interactive features,\nplease install tkinterweb:\n\npip install tkinterweb"),
|
126
|
+
justify=tk.CENTER
|
127
|
+
).pack(pady=10)
|
128
|
+
|
129
|
+
ttk.Button(
|
130
|
+
frame,
|
131
|
+
text="Use Text-Only Version",
|
132
|
+
command=self._switch_to_text_view
|
133
|
+
).pack(pady=10)
|
134
|
+
|
135
|
+
def _switch_to_text_view(self):
|
136
|
+
"""テキスト版のチャットビューに切り替え"""
|
137
|
+
# 親ウィジェットを取得してビューを切り替え
|
138
|
+
workbench = get_workbench()
|
139
|
+
# 現在のビューを閉じて、テキスト版を開く
|
140
|
+
workbench.show_view("LLMChatView")
|
141
|
+
|
142
|
+
def _init_ui(self):
|
143
|
+
"""UIコンポーネントを初期化"""
|
144
|
+
# メインコンテナ
|
145
|
+
self.columnconfigure(0, weight=1)
|
146
|
+
self.rowconfigure(1, weight=1)
|
147
|
+
|
148
|
+
# 各UI要素を作成
|
149
|
+
self._create_header_frame()
|
150
|
+
self._create_html_frame()
|
151
|
+
self._create_streaming_frame()
|
152
|
+
self._create_input_frame()
|
153
|
+
self._setup_key_bindings()
|
154
|
+
|
155
|
+
def _create_header_frame(self):
|
156
|
+
"""ヘッダーフレームを作成"""
|
157
|
+
header_frame = ttk.Frame(self)
|
158
|
+
header_frame.grid(row=0, column=0, sticky="ew", padx=3, pady=2)
|
159
|
+
|
160
|
+
ttk.Label(header_frame, text=tr("LLM Assistant"), font=("", 10, "bold")).pack(side=tk.LEFT)
|
161
|
+
|
162
|
+
# Clearボタン
|
163
|
+
self.clear_button = ttk.Button(
|
164
|
+
header_frame,
|
165
|
+
text=tr("Clear"),
|
166
|
+
command=self._clear_chat,
|
167
|
+
width=8
|
168
|
+
)
|
169
|
+
self.clear_button.pack(side=tk.LEFT, padx=5)
|
170
|
+
|
171
|
+
# 設定ボタン
|
172
|
+
self.settings_button = ttk.Button(
|
173
|
+
header_frame,
|
174
|
+
text="⚙",
|
175
|
+
width=3,
|
176
|
+
command=self._show_settings
|
177
|
+
)
|
178
|
+
self.settings_button.pack(side=tk.RIGHT, padx=2)
|
179
|
+
|
180
|
+
# ステータスフレーム
|
181
|
+
status_frame = ttk.Frame(header_frame)
|
182
|
+
status_frame.pack(side=tk.RIGHT, padx=3)
|
183
|
+
|
184
|
+
self.status_label = ttk.Label(status_frame, text=tr("No model loaded"), foreground="gray")
|
185
|
+
self.status_label.pack(side=tk.RIGHT)
|
186
|
+
|
187
|
+
def _create_html_frame(self):
|
188
|
+
"""HTMLフレームを作成"""
|
189
|
+
self.html_frame = HtmlFrame(self, messages_enabled=False, javascript_enabled=True)
|
190
|
+
self.html_frame.grid(row=1, column=0, sticky="nsew", padx=3, pady=2)
|
191
|
+
|
192
|
+
# URL変更のハンドラーを設定(Insert機能用)
|
193
|
+
self.html_frame.on_url_change = self._handle_url_change
|
194
|
+
|
195
|
+
# JavaScriptインターフェースを設定(HTML読み込み前に登録)
|
196
|
+
self._setup_js_interface()
|
197
|
+
|
198
|
+
# 初期HTMLを設定
|
199
|
+
self._update_html(full_reload=True)
|
200
|
+
|
201
|
+
# 初期状態では空のHTMLなのですぐに準備完了とみなす
|
202
|
+
if not self.messages:
|
203
|
+
self._html_ready = True
|
204
|
+
|
205
|
+
def _create_streaming_frame(self):
|
206
|
+
"""ストリーミング表示エリアを作成"""
|
207
|
+
self.streaming_frame = ttk.LabelFrame(self, text=tr("Generating..."), padding=5)
|
208
|
+
# 初期状態では非表示
|
209
|
+
|
210
|
+
# ストリーミング用のテキストウィジェット
|
211
|
+
from tkinter import scrolledtext
|
212
|
+
self.streaming_text = scrolledtext.ScrolledText(
|
213
|
+
self.streaming_frame,
|
214
|
+
height=6,
|
215
|
+
wrap=tk.WORD,
|
216
|
+
font=("Consolas", 10),
|
217
|
+
background="#f8f8f8",
|
218
|
+
foreground="#333333",
|
219
|
+
padx=10,
|
220
|
+
pady=5
|
221
|
+
)
|
222
|
+
self.streaming_text.pack(fill=tk.BOTH, expand=True)
|
223
|
+
self.streaming_text.config(state=tk.DISABLED) # 読み取り専用
|
224
|
+
|
225
|
+
def _create_input_frame(self):
|
226
|
+
"""入力エリアを作成"""
|
227
|
+
input_frame = ttk.Frame(self)
|
228
|
+
input_frame.grid(row=3, column=0, sticky="ew", padx=3, pady=2)
|
229
|
+
input_frame.columnconfigure(0, weight=1)
|
230
|
+
|
231
|
+
# 入力テキスト
|
232
|
+
self.input_text = tk.Text(
|
233
|
+
input_frame,
|
234
|
+
height=3,
|
235
|
+
font=("Consolas", 10),
|
236
|
+
wrap=tk.WORD
|
237
|
+
)
|
238
|
+
self.input_text.grid(row=0, column=0, sticky="ew", pady=(0, 2))
|
239
|
+
|
240
|
+
# ボタンフレーム
|
241
|
+
button_frame = ttk.Frame(input_frame)
|
242
|
+
button_frame.grid(row=1, column=0, sticky="ew")
|
243
|
+
|
244
|
+
self._create_buttons(button_frame)
|
245
|
+
|
246
|
+
# コンテキストマネージャー
|
247
|
+
self.context_manager = None
|
248
|
+
|
249
|
+
def _create_buttons(self, button_frame):
|
250
|
+
"""ボタン類を作成"""
|
251
|
+
# Sendボタン
|
252
|
+
self.send_button = ttk.Button(
|
253
|
+
button_frame,
|
254
|
+
text=tr("Send"),
|
255
|
+
command=self._handle_send_button,
|
256
|
+
state=tk.DISABLED
|
257
|
+
)
|
258
|
+
self.send_button.pack(side=tk.RIGHT, padx=2)
|
259
|
+
|
260
|
+
# Ctrl+Enterのヒント
|
261
|
+
hint_label = ttk.Label(
|
262
|
+
button_frame,
|
263
|
+
text=tr("Ctrl+Enter to send"),
|
264
|
+
foreground="gray",
|
265
|
+
font=("", 9)
|
266
|
+
)
|
267
|
+
hint_label.pack(side=tk.RIGHT, padx=3)
|
268
|
+
|
269
|
+
# プリセットボタン
|
270
|
+
ttk.Button(
|
271
|
+
button_frame,
|
272
|
+
text=tr("Explain Error"),
|
273
|
+
command=self._explain_last_error,
|
274
|
+
width=15
|
275
|
+
).pack(side=tk.LEFT, padx=2)
|
276
|
+
|
277
|
+
# コンテキストボタン
|
278
|
+
self.context_var = tk.BooleanVar(value=False)
|
279
|
+
self.context_check = ttk.Checkbutton(
|
280
|
+
button_frame,
|
281
|
+
text=tr("Include Context"),
|
282
|
+
variable=self.context_var,
|
283
|
+
command=self._toggle_context
|
284
|
+
)
|
285
|
+
self.context_check.pack(side=tk.LEFT, padx=5)
|
286
|
+
|
287
|
+
def _setup_key_bindings(self):
|
288
|
+
"""キーバインディングを設定"""
|
289
|
+
self.input_text.bind("<Control-Return>", lambda e: (self._handle_send_button(), "break")[1])
|
290
|
+
self.input_text.bind("<Shift-Return>", lambda e: "break")
|
291
|
+
|
292
|
+
# Escapeキーで生成を停止
|
293
|
+
self.bind_all("<Escape>", lambda e: self._stop_if_processing())
|
294
|
+
|
295
|
+
def _show_notification(self, message, notification_type="success"):
|
296
|
+
"""一時的な通知を表示"""
|
297
|
+
# ステータスラベルに一時的に表示
|
298
|
+
original_text = self.status_label.cget("text")
|
299
|
+
original_color = self.status_label.cget("foreground")
|
300
|
+
|
301
|
+
# タイプに応じて色を設定
|
302
|
+
color = "green" if notification_type == "success" else "red"
|
303
|
+
self.status_label.config(text=message, foreground=color)
|
304
|
+
|
305
|
+
# 2秒後に元に戻す
|
306
|
+
self.after(2000, lambda: self.status_label.config(text=original_text, foreground=original_color))
|
307
|
+
|
308
|
+
def _setup_js_interface(self):
|
309
|
+
"""JavaScriptとのインターフェースを設定"""
|
310
|
+
try:
|
311
|
+
# Python関数をJavaScriptから呼び出せるように登録
|
312
|
+
self.html_frame.register_JS_object("pyInsertCode", self._insert_code)
|
313
|
+
self.html_frame.register_JS_object("pyCopyCode", self._copy_code)
|
314
|
+
logger.info("JavaScript API registered successfully")
|
315
|
+
except Exception as e:
|
316
|
+
logger.error(f"Failed to setup JavaScript interface: {e}")
|
317
|
+
|
318
|
+
def _insert_code(self, code):
|
319
|
+
"""JavaScriptから呼ばれるコード挿入関数"""
|
320
|
+
try:
|
321
|
+
workbench = get_workbench()
|
322
|
+
editor = workbench.get_editor_notebook().get_current_editor()
|
323
|
+
if editor:
|
324
|
+
text_widget = editor.get_text_widget()
|
325
|
+
text_widget.insert("insert", code)
|
326
|
+
text_widget.focus_set()
|
327
|
+
self._show_notification("Code inserted into editor!")
|
328
|
+
return True
|
329
|
+
else:
|
330
|
+
self._show_notification("Please open a file in the editor first", "error")
|
331
|
+
return False
|
332
|
+
except Exception as e:
|
333
|
+
logger.error(f"Error inserting code: {e}")
|
334
|
+
return False
|
335
|
+
|
336
|
+
def _copy_code(self, code):
|
337
|
+
"""JavaScriptから呼ばれるコードコピー関数"""
|
338
|
+
try:
|
339
|
+
self.clipboard_clear()
|
340
|
+
self.clipboard_append(code)
|
341
|
+
self.update()
|
342
|
+
return True
|
343
|
+
except Exception as e:
|
344
|
+
logger.error(f"Error copying code: {e}")
|
345
|
+
return False
|
346
|
+
|
347
|
+
def _handle_url_change(self, url):
|
348
|
+
"""URL変更を処理(Insert機能用)"""
|
349
|
+
if url.startswith("thonny:insert:"):
|
350
|
+
import urllib.parse
|
351
|
+
code = urllib.parse.unquote(url[14:])
|
352
|
+
|
353
|
+
workbench = get_workbench()
|
354
|
+
editor = workbench.get_editor_notebook().get_current_editor()
|
355
|
+
if editor:
|
356
|
+
text_widget = editor.get_text_widget()
|
357
|
+
text_widget.insert("insert", code)
|
358
|
+
text_widget.focus_set()
|
359
|
+
self._show_notification(tr("Code inserted into editor!"))
|
360
|
+
else:
|
361
|
+
messagebox.showinfo(tr("No Editor"), tr("Please open a file in the editor first."))
|
362
|
+
|
363
|
+
# URLをリセットするため、空のページに戻す
|
364
|
+
# HTMLの再読み込みは避ける(ボタンが使えなくなるため)
|
365
|
+
try:
|
366
|
+
self.html_frame.stop() # 現在のナビゲーションを停止
|
367
|
+
except:
|
368
|
+
pass
|
369
|
+
|
370
|
+
return True # すべてのナビゲーションをキャンセル
|
371
|
+
|
372
|
+
@measure_performance("chat_view.update_html")
|
373
|
+
def _update_html(self, full_reload=True):
|
374
|
+
"""HTMLコンテンツを更新"""
|
375
|
+
if full_reload:
|
376
|
+
# 完全な再読み込み(初回やクリア時)
|
377
|
+
self._html_ready = False
|
378
|
+
html_content = self.markdown_renderer.get_full_html(self.messages)
|
379
|
+
|
380
|
+
# HTMLを読み込み(HTML内のJavaScriptで自動的に表示される)
|
381
|
+
self.html_frame.load_html(html_content)
|
382
|
+
|
383
|
+
# HTMLの読み込み完了を待つためのチェック
|
384
|
+
self.after(100, self._check_html_ready)
|
385
|
+
|
386
|
+
# スクロール管理システムを初期化(HTMLロード後)
|
387
|
+
self.after(200, lambda: self._init_scroll_manager() if not hasattr(self, '_scroll_manager_initialized') else None)
|
388
|
+
|
389
|
+
# 完全読み込み後にスクロール(初回のみ)
|
390
|
+
if not self.messages or len(self.messages) <= 1:
|
391
|
+
self._scroll_to_bottom()
|
392
|
+
else:
|
393
|
+
# ストリーミング中は何もしない(ストリーミングテキストエリアで表示)
|
394
|
+
pass
|
395
|
+
|
396
|
+
def _update_last_message_js(self, message_html):
|
397
|
+
"""JavaScriptで最後のメッセージのみ更新"""
|
398
|
+
# 新しいアプローチでは使用しない
|
399
|
+
pass
|
400
|
+
|
401
|
+
@measure_performance("chat_view.append_message_js")
|
402
|
+
def _append_message_js(self, sender: str, text: str):
|
403
|
+
"""JavaScriptで新しいメッセージを追加"""
|
404
|
+
try:
|
405
|
+
# HTMLが準備できていない場合は全体更新
|
406
|
+
if not self._html_ready:
|
407
|
+
self._update_html(full_reload=True)
|
408
|
+
return
|
409
|
+
|
410
|
+
# HTMLを生成
|
411
|
+
message_html = self.markdown_renderer.render(text, sender)
|
412
|
+
|
413
|
+
# HTMLをJavaScript文字列としてエスケープ
|
414
|
+
escaped_html = (message_html
|
415
|
+
.replace('\\', '\\\\')
|
416
|
+
.replace('\n', '\\n')
|
417
|
+
.replace('\r', '\\r')
|
418
|
+
.replace('"', '\\"')
|
419
|
+
.replace('</script>', '<\\/script>'))
|
420
|
+
|
421
|
+
js_code = f"""
|
422
|
+
(function() {{
|
423
|
+
var messagesDiv = document.getElementById('messages');
|
424
|
+
if (!messagesDiv) {{
|
425
|
+
console.error('Messages container not found');
|
426
|
+
return false;
|
427
|
+
}}
|
428
|
+
|
429
|
+
// 新しいメッセージを追加
|
430
|
+
messagesDiv.insertAdjacentHTML('beforeend', "{escaped_html}");
|
431
|
+
return true;
|
432
|
+
}})();
|
433
|
+
"""
|
434
|
+
result = self.html_frame.run_javascript(js_code)
|
435
|
+
|
436
|
+
# JavaScriptの実行に失敗した場合は全体更新
|
437
|
+
if not result:
|
438
|
+
self._update_html(full_reload=True)
|
439
|
+
|
440
|
+
except Exception as e:
|
441
|
+
logger.error(f"Could not append message: {e}")
|
442
|
+
# エラーの場合はフォールバックとして完全更新
|
443
|
+
self._update_html(full_reload=True)
|
444
|
+
|
445
|
+
|
446
|
+
def _check_html_ready(self):
|
447
|
+
"""HTMLの読み込み完了をチェック"""
|
448
|
+
# タイムアウト設定(10秒)
|
449
|
+
if not hasattr(self, '_html_ready_check_count'):
|
450
|
+
self._html_ready_check_count = 0
|
451
|
+
|
452
|
+
self._html_ready_check_count += 1
|
453
|
+
if self._html_ready_check_count > 200: # 50ms * 200 = 10秒
|
454
|
+
logger.warning("HTML ready check timeout - proceeding anyway")
|
455
|
+
self._html_ready = True
|
456
|
+
return
|
457
|
+
|
458
|
+
try:
|
459
|
+
# JavaScriptでDOMの準備状態をチェック
|
460
|
+
js_code = """
|
461
|
+
(function() {
|
462
|
+
return document.readyState === 'complete' &&
|
463
|
+
document.getElementById('messages') !== null &&
|
464
|
+
typeof pyInsertCode !== 'undefined' &&
|
465
|
+
typeof pyCopyCode !== 'undefined' &&
|
466
|
+
window.pageReady === true;
|
467
|
+
})();
|
468
|
+
"""
|
469
|
+
result = self.html_frame.run_javascript(js_code)
|
470
|
+
if result:
|
471
|
+
self._html_ready = True
|
472
|
+
logger.debug("HTML is ready")
|
473
|
+
# HTMLが準備完了したらスクロール管理システムを初期化
|
474
|
+
if not hasattr(self, '_scroll_manager_initialized'):
|
475
|
+
self._init_scroll_manager()
|
476
|
+
self._scroll_manager_initialized = True
|
477
|
+
else:
|
478
|
+
# まだ準備ができていない場合は再チェック
|
479
|
+
self.after(50, self._check_html_ready)
|
480
|
+
except Exception as e:
|
481
|
+
logger.debug(f"HTML readiness check error: {e}")
|
482
|
+
# エラーの場合も再チェック(タイムアウトまで)
|
483
|
+
if self._html_ready_check_count < 200:
|
484
|
+
self.after(50, self._check_html_ready)
|
485
|
+
|
486
|
+
|
487
|
+
def _init_scroll_manager(self):
|
488
|
+
"""JavaScriptスクロール管理システムを初期化"""
|
489
|
+
# シンプルなスクロール管理
|
490
|
+
pass
|
491
|
+
|
492
|
+
def _scroll_to_bottom(self):
|
493
|
+
"""HTMLフレームを最下部にスクロール"""
|
494
|
+
try:
|
495
|
+
# tkinterwebの標準的なスクロールメソッドを使用
|
496
|
+
self.html_frame.yview_moveto(1.0)
|
497
|
+
except Exception as e:
|
498
|
+
logger.debug(f"Could not scroll to bottom: {e}")
|
499
|
+
|
500
|
+
|
501
|
+
def _init_llm(self):
|
502
|
+
"""LLMクライアントを初期化"""
|
503
|
+
try:
|
504
|
+
from .. import get_llm_client
|
505
|
+
from ..model_manager import ModelManager
|
506
|
+
|
507
|
+
self.llm_client = get_llm_client()
|
508
|
+
|
509
|
+
workbench = get_workbench()
|
510
|
+
provider = workbench.get_option("llm.provider", "local")
|
511
|
+
|
512
|
+
if provider == "local":
|
513
|
+
# ローカルモデルの場合
|
514
|
+
model_path = workbench.get_option("llm.model_path", "")
|
515
|
+
|
516
|
+
if not model_path or not Path(model_path).exists():
|
517
|
+
# モデルがない場合
|
518
|
+
manager = ModelManager()
|
519
|
+
available_model = manager.get_model_path("llama3.2-1b") or manager.get_model_path("llama3.2-3b")
|
520
|
+
|
521
|
+
if available_model:
|
522
|
+
workbench.set_option("llm.model_path", available_model)
|
523
|
+
model_path = available_model
|
524
|
+
else:
|
525
|
+
self.status_label.config(text=tr("No model loaded"), foreground="red")
|
526
|
+
self._add_message(
|
527
|
+
"system",
|
528
|
+
tr("No model found. Please download a model from Settings → Download Models.")
|
529
|
+
)
|
530
|
+
|
531
|
+
if messagebox.askyesno(
|
532
|
+
"No Model Found",
|
533
|
+
"No LLM model found. Would you like to download recommended models?",
|
534
|
+
parent=self
|
535
|
+
):
|
536
|
+
self._show_settings()
|
537
|
+
return
|
538
|
+
|
539
|
+
# 非同期でモデルをロード
|
540
|
+
model_name = Path(model_path).name if model_path else "model"
|
541
|
+
self.status_label.config(text=f"{tr('Loading')} {model_name}...", foreground="orange")
|
542
|
+
self.llm_client.load_model_async(callback=self._on_model_loaded)
|
543
|
+
else:
|
544
|
+
# 外部プロバイダーの場合
|
545
|
+
external_model = workbench.get_option("llm.external_model", "")
|
546
|
+
# 表示用のプロバイダー名
|
547
|
+
display_provider = "Ollama/LM Studio" if provider == "ollama" else provider
|
548
|
+
display_text = f"{external_model} ({display_provider})" if external_model else f"Using {display_provider}"
|
549
|
+
self.status_label.config(text=display_text, foreground="blue")
|
550
|
+
self.llm_client.get_config()
|
551
|
+
self.send_button.config(state=tk.NORMAL)
|
552
|
+
self._add_message(
|
553
|
+
"system",
|
554
|
+
tr("Connected to {} API. Ready to chat!").format(display_provider.upper())
|
555
|
+
)
|
556
|
+
|
557
|
+
except Exception as e:
|
558
|
+
import traceback
|
559
|
+
error_details = traceback.format_exc()
|
560
|
+
logger.error(f"Failed to initialize LLM client: {e}\n{error_details}")
|
561
|
+
self.status_label.config(text="Error loading model", foreground="red")
|
562
|
+
# ユーザーフレンドリーなエラーメッセージ
|
563
|
+
if "import" in str(e).lower():
|
564
|
+
user_message = tr("LLM module not installed. Please install llama-cpp-python.")
|
565
|
+
else:
|
566
|
+
user_message = f"{tr('Failed to initialize LLM')}: {str(e)}"
|
567
|
+
self._add_message("system", user_message)
|
568
|
+
|
569
|
+
def _on_model_loaded(self, success: bool, error: Optional[Exception]):
|
570
|
+
"""モデル読み込み完了時のコールバック"""
|
571
|
+
def update_ui():
|
572
|
+
if success:
|
573
|
+
model_path = get_workbench().get_option("llm.model_path", "")
|
574
|
+
model_name = Path(model_path).name if model_path else "Unknown"
|
575
|
+
self.status_label.config(text=f"{model_name} | {tr('Ready')}", foreground="green")
|
576
|
+
self.send_button.config(state=tk.NORMAL)
|
577
|
+
self._add_message("system", tr("LLM model loaded successfully!"))
|
578
|
+
else:
|
579
|
+
self.status_label.config(text=tr("Load failed"), foreground="red")
|
580
|
+
self._add_message("system", f"{tr('Failed to load model:')} {error}")
|
581
|
+
|
582
|
+
self.after(0, update_ui)
|
583
|
+
|
584
|
+
def _add_message(self, sender: str, text: str):
|
585
|
+
"""メッセージを追加してHTMLを更新"""
|
586
|
+
# メッセージ履歴の上限を設定(メモリ使用量を制限)
|
587
|
+
MAX_MESSAGES = 200 # メモリ上の最大メッセージ数
|
588
|
+
|
589
|
+
self.messages.append((sender, text))
|
590
|
+
|
591
|
+
# 古いメッセージを削除(最新のMAX_MESSAGES件のみ保持)
|
592
|
+
if len(self.messages) > MAX_MESSAGES:
|
593
|
+
# 最初の10%を削除してパフォーマンスを向上
|
594
|
+
remove_count = max(1, MAX_MESSAGES // 10)
|
595
|
+
self.messages = self.messages[remove_count:]
|
596
|
+
logger.debug(f"Trimmed {remove_count} old messages from memory")
|
597
|
+
|
598
|
+
# HTMLが準備できているかチェックしてメッセージを追加
|
599
|
+
if self._html_ready:
|
600
|
+
# JavaScriptで新しいメッセージを追加(全体再読み込みを避ける)
|
601
|
+
self._append_message_js(sender, text)
|
602
|
+
# ユーザーメッセージの場合のみ自動スクロール(会話開始時)
|
603
|
+
if sender == "user":
|
604
|
+
self._scroll_to_bottom()
|
605
|
+
else:
|
606
|
+
# HTMLが準備できていない場合は、準備完了を待ってから追加
|
607
|
+
self._add_message_when_ready(sender, text)
|
608
|
+
|
609
|
+
# ユーザーとアシスタントのメッセージのみ保存(システムメッセージは一時的なものが多いため)
|
610
|
+
if sender in ["user", "assistant"]:
|
611
|
+
self._save_chat_history()
|
612
|
+
|
613
|
+
def _add_message_when_ready(self, sender: str, text: str, retry_count=0):
|
614
|
+
"""HTMLが準備できたらメッセージを追加(既にメッセージリストには追加済み)"""
|
615
|
+
if self._html_ready:
|
616
|
+
# JavaScriptで新しいメッセージを追加
|
617
|
+
self._append_message_js(sender, text)
|
618
|
+
# ユーザーメッセージの場合のみスクロール
|
619
|
+
if sender == "user":
|
620
|
+
self._scroll_to_bottom()
|
621
|
+
else:
|
622
|
+
# まだ準備ができていない場合は再試行(最大100回)
|
623
|
+
if retry_count < 100:
|
624
|
+
self.after(50, lambda: self._add_message_when_ready(sender, text, retry_count + 1))
|
625
|
+
else:
|
626
|
+
# タイムアウト時は全体を再読み込み
|
627
|
+
self._update_html(full_reload=True)
|
628
|
+
|
629
|
+
def _handle_send_button(self):
|
630
|
+
"""送信/停止ボタンのハンドラー"""
|
631
|
+
if self._processing:
|
632
|
+
self._stop_generation = True
|
633
|
+
self.send_button.config(text=tr("Stopping..."))
|
634
|
+
# 停止メッセージは完了時に追加するので、ここでは追加しない
|
635
|
+
else:
|
636
|
+
self._send_message()
|
637
|
+
|
638
|
+
def _stop_if_processing(self):
|
639
|
+
"""処理中の場合は生成を停止"""
|
640
|
+
if self._processing:
|
641
|
+
self._stop_generation = True
|
642
|
+
self.send_button.config(text=tr("Stopping..."))
|
643
|
+
# 停止メッセージは完了時に追加するので、ここでは追加しない
|
644
|
+
|
645
|
+
def _send_message(self):
|
646
|
+
"""メッセージを送信"""
|
647
|
+
message = self.input_text.get("1.0", tk.END).strip()
|
648
|
+
if not message:
|
649
|
+
return
|
650
|
+
|
651
|
+
# UIをクリア
|
652
|
+
self.input_text.delete("1.0", tk.END)
|
653
|
+
|
654
|
+
# コンテキスト情報を取得して表示メッセージを作成
|
655
|
+
context_info = self._get_context_info()
|
656
|
+
display_message = self._format_display_message(message, context_info)
|
657
|
+
|
658
|
+
# ユーザーメッセージを追加
|
659
|
+
self._add_message("user", display_message)
|
660
|
+
|
661
|
+
# ユーザーメッセージ追加後に最下部にスクロール
|
662
|
+
self.after(100, self._scroll_to_bottom)
|
663
|
+
|
664
|
+
# 生成を開始
|
665
|
+
self._start_generation(message)
|
666
|
+
|
667
|
+
def _get_context_info(self) -> Optional[str]:
|
668
|
+
"""コンテキスト情報を取得"""
|
669
|
+
if not (self.context_var.get() and self.context_manager):
|
670
|
+
return None
|
671
|
+
|
672
|
+
workbench = get_workbench()
|
673
|
+
editor = workbench.get_editor_notebook().get_current_editor()
|
674
|
+
if not editor:
|
675
|
+
return None
|
676
|
+
|
677
|
+
current_file = editor.get_filename()
|
678
|
+
text_widget = editor.get_text_widget()
|
679
|
+
|
680
|
+
if text_widget.tag_ranges("sel"):
|
681
|
+
# 選択範囲がある場合
|
682
|
+
start_line = int(text_widget.index("sel.first").split(".")[0])
|
683
|
+
end_line = int(text_widget.index("sel.last").split(".")[0])
|
684
|
+
file_name = Path(current_file).name if current_file else "Unknown"
|
685
|
+
return f"Context: {file_name} (lines {start_line}-{end_line})"
|
686
|
+
elif current_file:
|
687
|
+
# ファイル全体の場合
|
688
|
+
file_name = Path(current_file).name
|
689
|
+
return f"Context: {file_name} (entire file)"
|
690
|
+
|
691
|
+
return None
|
692
|
+
|
693
|
+
def _format_display_message(self, message: str, context_info: Optional[str]) -> str:
|
694
|
+
"""表示用メッセージをフォーマット"""
|
695
|
+
if context_info:
|
696
|
+
return f"{message}\n\n[{context_info}]"
|
697
|
+
return message
|
698
|
+
|
699
|
+
def _start_generation(self, message: str):
|
700
|
+
"""生成処理を開始"""
|
701
|
+
# 処理中フラグを設定
|
702
|
+
self._processing = True
|
703
|
+
with self._message_lock:
|
704
|
+
self._current_message = ""
|
705
|
+
self._stop_generation = False
|
706
|
+
self._first_token_received = False
|
707
|
+
self.send_button.config(text="Stop", state=tk.NORMAL)
|
708
|
+
|
709
|
+
# 新しいメッセージIDを生成
|
710
|
+
import time
|
711
|
+
self._current_message_id = f"msg_{int(time.time() * 1000)}"
|
712
|
+
|
713
|
+
# グローバルの生成状態を更新
|
714
|
+
from .. import set_llm_busy
|
715
|
+
set_llm_busy(True)
|
716
|
+
|
717
|
+
# "Generating..."アニメーションを開始
|
718
|
+
self._start_generating_animation()
|
719
|
+
|
720
|
+
# バックグラウンドで処理
|
721
|
+
thread = threading.Thread(
|
722
|
+
target=self._generate_response,
|
723
|
+
args=(message,), # 元のメッセージを渡す(コンテキスト情報なし)
|
724
|
+
daemon=True
|
725
|
+
)
|
726
|
+
thread.start()
|
727
|
+
|
728
|
+
def _get_system_prompt(self) -> str:
|
729
|
+
"""スキルレベルと言語設定に応じたシステムプロンプトを生成(フォーマット文字列置換付き)"""
|
730
|
+
workbench = get_workbench()
|
731
|
+
|
732
|
+
# 設定値を取得
|
733
|
+
skill_level_setting = workbench.get_option("llm.skill_level", "beginner")
|
734
|
+
language_setting = workbench.get_option("llm.language", "auto")
|
735
|
+
custom_prompt = workbench.get_option("llm.custom_system_prompt", "")
|
736
|
+
|
737
|
+
# カスタムプロンプトが設定されている場合はそれを使用
|
738
|
+
if custom_prompt.strip():
|
739
|
+
template = custom_prompt
|
740
|
+
else:
|
741
|
+
# デフォルトテンプレート(共通定数から取得)
|
742
|
+
from ..prompts import DEFAULT_SYSTEM_PROMPT_TEMPLATE
|
743
|
+
template = DEFAULT_SYSTEM_PROMPT_TEMPLATE
|
744
|
+
|
745
|
+
# スキルレベルの詳細説明を生成(共通定数から取得)
|
746
|
+
from ..prompts import SKILL_LEVEL_DESCRIPTIONS
|
747
|
+
|
748
|
+
skill_level_detailed = SKILL_LEVEL_DESCRIPTIONS.get(skill_level_setting, skill_level_setting)
|
749
|
+
|
750
|
+
# フォーマット文字列を置換
|
751
|
+
try:
|
752
|
+
formatted_prompt = template.format(
|
753
|
+
skill_level=skill_level_detailed,
|
754
|
+
language=language_setting
|
755
|
+
)
|
756
|
+
return formatted_prompt
|
757
|
+
except KeyError as e:
|
758
|
+
# フォーマット文字列にエラーがある場合はそのまま返す
|
759
|
+
return template
|
760
|
+
|
761
|
+
def _prepare_conversation_history(self) -> list:
|
762
|
+
"""会話履歴をLLM用の形式に変換(システムプロンプト付き)"""
|
763
|
+
history = []
|
764
|
+
|
765
|
+
# システムプロンプトを最初に追加
|
766
|
+
system_prompt = self._get_system_prompt()
|
767
|
+
history.append({"role": "system", "content": system_prompt})
|
768
|
+
|
769
|
+
# 最新の会話履歴から適切な数だけ取得(メモリ制限のため)
|
770
|
+
workbench = get_workbench()
|
771
|
+
max_history = workbench.get_option("llm.max_conversation_history", 10) # デフォルト10ターン
|
772
|
+
|
773
|
+
# 現在生成中の場合、最新のユーザーメッセージは除外する
|
774
|
+
messages_to_process = self.messages[:-1] if self._processing else self.messages
|
775
|
+
|
776
|
+
for sender, text in messages_to_process[-max_history:]:
|
777
|
+
if sender == "user":
|
778
|
+
# コンテキスト情報を除去([Context: ...]の部分)
|
779
|
+
clean_text = text
|
780
|
+
if "\n\n[Context:" in text:
|
781
|
+
clean_text = text.split("\n\n[Context:")[0]
|
782
|
+
history.append({"role": "user", "content": clean_text})
|
783
|
+
elif sender == "assistant":
|
784
|
+
history.append({"role": "assistant", "content": text})
|
785
|
+
# システムメッセージ(UI上の)は除外(システムプロンプトとは別)
|
786
|
+
|
787
|
+
return history
|
788
|
+
|
789
|
+
def _generate_response(self, message: str):
|
790
|
+
"""バックグラウンドで応答を生成"""
|
791
|
+
try:
|
792
|
+
# 最新のLLMクライアントを取得(プロバイダー変更に対応)
|
793
|
+
from .. import get_llm_client
|
794
|
+
llm_client = get_llm_client()
|
795
|
+
|
796
|
+
# プロンプトと会話履歴を準備
|
797
|
+
full_prompt = self._prepare_prompt_with_context(message)
|
798
|
+
conversation_history = self._prepare_conversation_history()
|
799
|
+
|
800
|
+
# ストリーミング生成
|
801
|
+
self._stream_generation(llm_client, full_prompt, conversation_history)
|
802
|
+
|
803
|
+
except Exception as e:
|
804
|
+
self._handle_generation_error(e)
|
805
|
+
|
806
|
+
def _prepare_prompt_with_context(self, message: str) -> str:
|
807
|
+
"""コンテキストを含むプロンプトを準備"""
|
808
|
+
if not (self.context_var.get() and self.context_manager):
|
809
|
+
return message
|
810
|
+
|
811
|
+
context_str = self._build_context_string()
|
812
|
+
if context_str:
|
813
|
+
return f"""Here is the context from the current project:
|
814
|
+
|
815
|
+
{context_str}
|
816
|
+
|
817
|
+
Based on this context, {message}"""
|
818
|
+
|
819
|
+
return message
|
820
|
+
|
821
|
+
def _build_context_string(self) -> Optional[str]:
|
822
|
+
"""コンテキスト文字列を構築"""
|
823
|
+
workbench = get_workbench()
|
824
|
+
editor = workbench.get_editor_notebook().get_current_editor()
|
825
|
+
if not editor:
|
826
|
+
return None
|
827
|
+
|
828
|
+
current_file = editor.get_filename()
|
829
|
+
text_widget = editor.get_text_widget()
|
830
|
+
|
831
|
+
# 選択範囲のテキストを取得
|
832
|
+
selected_text = self._get_selected_text(text_widget)
|
833
|
+
if selected_text:
|
834
|
+
return self._format_selected_context(current_file, text_widget, selected_text)
|
835
|
+
|
836
|
+
# 選択範囲がない場合はファイル全体
|
837
|
+
if current_file:
|
838
|
+
return self._format_full_file_context(current_file, text_widget)
|
839
|
+
|
840
|
+
return None
|
841
|
+
|
842
|
+
def _get_selected_text(self, text_widget) -> Optional[str]:
|
843
|
+
"""選択されたテキストを取得"""
|
844
|
+
if text_widget.tag_ranges("sel"):
|
845
|
+
return text_widget.get("sel.first", "sel.last")
|
846
|
+
return None
|
847
|
+
|
848
|
+
def _format_selected_context(self, current_file: str, text_widget, selected_text: str) -> str:
|
849
|
+
"""選択範囲のコンテキストをフォーマット"""
|
850
|
+
start_line = int(text_widget.index("sel.first").split(".")[0])
|
851
|
+
end_line = int(text_widget.index("sel.last").split(".")[0])
|
852
|
+
selection_info = f"Selected lines: {start_line}-{end_line}"
|
853
|
+
|
854
|
+
lang = self._detect_language(current_file)
|
855
|
+
file_name = Path(current_file).name if current_file else 'Unknown'
|
856
|
+
|
857
|
+
return f"""File: {file_name}
|
858
|
+
{selection_info}
|
859
|
+
|
860
|
+
```{lang}
|
861
|
+
{selected_text}
|
862
|
+
```"""
|
863
|
+
|
864
|
+
def _format_full_file_context(self, current_file: str, text_widget) -> Optional[str]:
|
865
|
+
"""ファイル全体のコンテキストをフォーマット"""
|
866
|
+
full_text = text_widget.get("1.0", tk.END).strip()
|
867
|
+
if not full_text:
|
868
|
+
return None
|
869
|
+
|
870
|
+
lang = self._detect_language(current_file)
|
871
|
+
file_name = Path(current_file).name
|
872
|
+
|
873
|
+
return f"""File: {file_name}
|
874
|
+
Full file content:
|
875
|
+
|
876
|
+
```{lang}
|
877
|
+
{full_text}
|
878
|
+
```"""
|
879
|
+
|
880
|
+
def _detect_language(self, file_path: str) -> str:
|
881
|
+
"""ファイル拡張子から言語を検出"""
|
882
|
+
if not file_path:
|
883
|
+
return 'python'
|
884
|
+
|
885
|
+
file_ext = Path(file_path).suffix.lower()
|
886
|
+
lang_map = {'.py': 'python', '.js': 'javascript', '.java': 'java', '.cpp': 'cpp', '.c': 'c'}
|
887
|
+
return lang_map.get(file_ext, 'python')
|
888
|
+
|
889
|
+
def _stream_generation(self, llm_client, prompt: str, conversation_history: list):
|
890
|
+
"""LLMからストリーミング生成"""
|
891
|
+
for token in llm_client.generate_stream(prompt, messages=conversation_history):
|
892
|
+
if self._stop_generation:
|
893
|
+
self.message_queue.put(("complete", None))
|
894
|
+
return
|
895
|
+
self.message_queue.put(("token", token))
|
896
|
+
|
897
|
+
self.message_queue.put(("complete", None))
|
898
|
+
|
899
|
+
def _handle_generation_error(self, error: Exception):
|
900
|
+
"""生成エラーを処理"""
|
901
|
+
import traceback
|
902
|
+
error_details = traceback.format_exc()
|
903
|
+
logger.error(f"Error generating response: {error}\n{error_details}")
|
904
|
+
|
905
|
+
# ユーザーフレンドリーなエラーメッセージ
|
906
|
+
from ..utils.error_messages import get_user_friendly_error_message
|
907
|
+
user_message = get_user_friendly_error_message(error, "generating response")
|
908
|
+
self.message_queue.put(("error", user_message))
|
909
|
+
|
910
|
+
def _process_queue(self):
|
911
|
+
"""メッセージキューを処理"""
|
912
|
+
try:
|
913
|
+
while True:
|
914
|
+
msg_type, content = self.message_queue.get_nowait()
|
915
|
+
|
916
|
+
if msg_type == "token":
|
917
|
+
self._handle_token(content)
|
918
|
+
elif msg_type == "complete":
|
919
|
+
self._handle_completion()
|
920
|
+
elif msg_type == "error":
|
921
|
+
self._handle_error(content)
|
922
|
+
elif msg_type == "info":
|
923
|
+
self._add_message("system", content)
|
924
|
+
|
925
|
+
except queue.Empty:
|
926
|
+
pass
|
927
|
+
|
928
|
+
# 次のチェックをスケジュール
|
929
|
+
self._queue_check_id = self.after(50, self._process_queue)
|
930
|
+
|
931
|
+
def _handle_token(self, content: str):
|
932
|
+
"""トークンを処理"""
|
933
|
+
# 最初のトークンを受け取ったら準備完了
|
934
|
+
if not self._first_token_received:
|
935
|
+
self._first_token_received = True
|
936
|
+
# ストリーミングフレームのタイトルを更新
|
937
|
+
self.streaming_frame.config(text=tr("Assistant"))
|
938
|
+
|
939
|
+
with self._message_lock:
|
940
|
+
self._current_message += content
|
941
|
+
|
942
|
+
# ストリーミングテキストに追加表示
|
943
|
+
self._update_streaming_text(content)
|
944
|
+
|
945
|
+
def _update_streaming_text(self, content: str):
|
946
|
+
"""ストリーミングテキストを更新"""
|
947
|
+
self.streaming_text.config(state=tk.NORMAL)
|
948
|
+
self.streaming_text.insert(tk.END, content)
|
949
|
+
self.streaming_text.see(tk.END)
|
950
|
+
self.streaming_text.config(state=tk.DISABLED)
|
951
|
+
|
952
|
+
def _handle_completion(self):
|
953
|
+
"""生成完了を処理"""
|
954
|
+
# ストリーミングエリアを非表示にしてHTMLビューに転送
|
955
|
+
self._stop_generating_animation()
|
956
|
+
|
957
|
+
# 現在のメッセージがある場合、HTMLビューに転送
|
958
|
+
with self._message_lock:
|
959
|
+
current_msg = self._current_message
|
960
|
+
|
961
|
+
if current_msg:
|
962
|
+
self._finalize_assistant_message(current_msg)
|
963
|
+
elif self._stop_generation:
|
964
|
+
# メッセージがない場合でも停止メッセージを追加
|
965
|
+
self._add_message("system", tr("[Generation stopped by user]"))
|
966
|
+
|
967
|
+
self._reset_generation_state()
|
968
|
+
|
969
|
+
def _finalize_assistant_message(self, message: str):
|
970
|
+
"""アシスタントメッセージを完了"""
|
971
|
+
# アシスタントメッセージを追加
|
972
|
+
self.messages.append(("assistant", message))
|
973
|
+
|
974
|
+
# HTMLビューに完全なメッセージを表示
|
975
|
+
self._update_html(full_reload=True)
|
976
|
+
|
977
|
+
# HTMLの更新完了後に最下部にスクロール
|
978
|
+
self.after(200, self._scroll_to_bottom)
|
979
|
+
|
980
|
+
with self._message_lock:
|
981
|
+
self._current_message = ""
|
982
|
+
|
983
|
+
# 停止された場合のみ停止メッセージを追加
|
984
|
+
if self._stop_generation:
|
985
|
+
self._add_message("system", tr("[Generation stopped by user]"))
|
986
|
+
|
987
|
+
def _handle_error(self, error_message: str):
|
988
|
+
"""エラーを処理"""
|
989
|
+
# ストリーミングエリアを非表示
|
990
|
+
self._stop_generating_animation()
|
991
|
+
|
992
|
+
self._add_message("system", f"Error: {error_message}")
|
993
|
+
self._reset_generation_state()
|
994
|
+
|
995
|
+
def _reset_generation_state(self):
|
996
|
+
"""生成状態をリセット"""
|
997
|
+
self._processing = False
|
998
|
+
self._stop_generation = False
|
999
|
+
self.send_button.config(text=tr("Send"), state=tk.NORMAL)
|
1000
|
+
|
1001
|
+
# グローバルの生成状態を解除
|
1002
|
+
from .. import set_llm_busy
|
1003
|
+
set_llm_busy(False)
|
1004
|
+
|
1005
|
+
def _delayed_update(self):
|
1006
|
+
"""遅延更新を実行(ストリーミング中は使用しない)"""
|
1007
|
+
# 新しいアプローチでは使用しない
|
1008
|
+
pass
|
1009
|
+
|
1010
|
+
def explain_code(self, code: str):
|
1011
|
+
"""コードを説明(外部から呼ばれる)"""
|
1012
|
+
# 既に生成中の場合は何もしない(ハンドラー側でチェック済み)
|
1013
|
+
if self._processing:
|
1014
|
+
return
|
1015
|
+
|
1016
|
+
# 言語を検出
|
1017
|
+
lang = self._detect_current_file_language()
|
1018
|
+
|
1019
|
+
# 説明用のプロンプトを生成
|
1020
|
+
message = self._build_code_explanation_prompt(code, lang)
|
1021
|
+
|
1022
|
+
# プロンプトを入力して送信
|
1023
|
+
self.input_text.delete("1.0", tk.END)
|
1024
|
+
self.input_text.insert("1.0", message)
|
1025
|
+
self._send_message()
|
1026
|
+
|
1027
|
+
def _detect_current_file_language(self) -> str:
|
1028
|
+
"""現在のファイルから言語を検出"""
|
1029
|
+
workbench = get_workbench()
|
1030
|
+
editor = workbench.get_editor_notebook().get_current_editor()
|
1031
|
+
|
1032
|
+
if editor:
|
1033
|
+
filename = editor.get_filename()
|
1034
|
+
if filename:
|
1035
|
+
return self._detect_language(filename)
|
1036
|
+
|
1037
|
+
return 'python' # デフォルト
|
1038
|
+
|
1039
|
+
def _build_code_explanation_prompt(self, code: str, lang: str) -> str:
|
1040
|
+
"""コード説明用のプロンプトを構築"""
|
1041
|
+
workbench = get_workbench()
|
1042
|
+
|
1043
|
+
# 言語設定を取得
|
1044
|
+
language_setting = self._get_language_setting(workbench)
|
1045
|
+
|
1046
|
+
# スキルレベルの指示を取得
|
1047
|
+
skill_instruction = self._get_code_explanation_instruction(workbench)
|
1048
|
+
|
1049
|
+
# 言語別のプロンプトを構築
|
1050
|
+
if language_setting == "Japanese":
|
1051
|
+
return f"{skill_instruction}\n\n以下のコードを説明してください:\n```{lang}\n{code}\n```"
|
1052
|
+
else: # English
|
1053
|
+
return f"{skill_instruction}\n\nPlease explain this code:\n```{lang}\n{code}\n```"
|
1054
|
+
|
1055
|
+
def _get_code_explanation_instruction(self, workbench_settings) -> str:
|
1056
|
+
"""コード説明用のスキルレベル指示を取得"""
|
1057
|
+
skill_level = workbench_settings.get_option("llm.skill_level", "beginner")
|
1058
|
+
|
1059
|
+
skill_instructions = {
|
1060
|
+
"beginner": "Explain in simple terms for a beginner. Avoid technical jargon and use plain language.",
|
1061
|
+
"intermediate": "Explain assuming basic programming knowledge.",
|
1062
|
+
"advanced": "Provide a detailed explanation including algorithmic efficiency and design considerations."
|
1063
|
+
}
|
1064
|
+
|
1065
|
+
return skill_instructions.get(skill_level, skill_instructions["beginner"])
|
1066
|
+
|
1067
|
+
def _explain_last_error(self):
|
1068
|
+
"""最後のエラーを説明"""
|
1069
|
+
try:
|
1070
|
+
error_message = self._extract_error_from_shell()
|
1071
|
+
if not error_message:
|
1072
|
+
messagebox.showinfo("No Error", "No recent error found in shell.")
|
1073
|
+
return
|
1074
|
+
|
1075
|
+
code = self._get_current_editor_code()
|
1076
|
+
prompt = self._build_error_explanation_prompt(error_message, code)
|
1077
|
+
|
1078
|
+
self.input_text.delete("1.0", tk.END)
|
1079
|
+
self.input_text.insert("1.0", prompt)
|
1080
|
+
self._send_message()
|
1081
|
+
|
1082
|
+
except Exception as e:
|
1083
|
+
self._handle_explain_error_failure(e)
|
1084
|
+
|
1085
|
+
def _extract_error_from_shell(self) -> Optional[str]:
|
1086
|
+
"""シェルからエラーメッセージを抽出"""
|
1087
|
+
shell_view = get_workbench().get_view("ShellView")
|
1088
|
+
if not shell_view:
|
1089
|
+
return None
|
1090
|
+
|
1091
|
+
shell_text = shell_view.text
|
1092
|
+
shell_content = shell_text.get("1.0", tk.END)
|
1093
|
+
lines = shell_content.strip().split('\n')
|
1094
|
+
|
1095
|
+
error_lines = []
|
1096
|
+
error_found = False
|
1097
|
+
|
1098
|
+
for i in range(len(lines) - 1, -1, -1):
|
1099
|
+
line = lines[i]
|
1100
|
+
|
1101
|
+
if error_found and (line.startswith(">>>") or line.startswith("===") or not line.strip()):
|
1102
|
+
break
|
1103
|
+
|
1104
|
+
if any(error_type in line for error_type in ["Error", "Exception", "Traceback"]):
|
1105
|
+
error_found = True
|
1106
|
+
|
1107
|
+
if error_found:
|
1108
|
+
error_lines.insert(0, line)
|
1109
|
+
|
1110
|
+
return '\n'.join(error_lines) if error_lines else None
|
1111
|
+
|
1112
|
+
def _get_current_editor_code(self) -> str:
|
1113
|
+
"""現在のエディタのコードを取得"""
|
1114
|
+
editor = get_workbench().get_editor_notebook().get_current_editor()
|
1115
|
+
if editor:
|
1116
|
+
try:
|
1117
|
+
return editor.get_text_widget().get("1.0", tk.END).strip()
|
1118
|
+
except:
|
1119
|
+
pass
|
1120
|
+
return ""
|
1121
|
+
|
1122
|
+
def _build_error_explanation_prompt(self, error_message: str, code: str) -> str:
|
1123
|
+
"""エラー説明用のプロンプトを構築"""
|
1124
|
+
workbench_settings = get_workbench()
|
1125
|
+
|
1126
|
+
# 言語設定を取得
|
1127
|
+
language_setting = self._get_language_setting(workbench_settings)
|
1128
|
+
|
1129
|
+
# スキルレベルの指示を取得
|
1130
|
+
skill_instruction = self._get_skill_instruction(workbench_settings)
|
1131
|
+
|
1132
|
+
# 言語別のプロンプトを構築
|
1133
|
+
return self._format_error_prompt(language_setting, skill_instruction, error_message, code)
|
1134
|
+
|
1135
|
+
def _get_language_setting(self, workbench_settings) -> str:
|
1136
|
+
"""言語設定を取得"""
|
1137
|
+
language_setting = workbench_settings.get_option("llm.output_language", "auto")
|
1138
|
+
if language_setting == "auto":
|
1139
|
+
thonny_lang = workbench_settings.get_option("general.language", "en")
|
1140
|
+
return "Japanese" if thonny_lang.startswith("ja") else "English"
|
1141
|
+
elif language_setting == "ja":
|
1142
|
+
return "Japanese"
|
1143
|
+
elif language_setting == "en":
|
1144
|
+
return "English"
|
1145
|
+
return "English"
|
1146
|
+
|
1147
|
+
def _get_skill_instruction(self, workbench_settings) -> str:
|
1148
|
+
"""スキルレベルに応じた指示を取得"""
|
1149
|
+
skill_level = workbench_settings.get_option("llm.skill_level", "beginner")
|
1150
|
+
|
1151
|
+
skill_instructions = {
|
1152
|
+
"beginner": "Explain the error in simple terms for a beginner and provide clear solutions.",
|
1153
|
+
"intermediate": "Explain the error and solutions assuming basic programming knowledge.",
|
1154
|
+
"advanced": "Provide a technical explanation of the error and efficient solutions."
|
1155
|
+
}
|
1156
|
+
|
1157
|
+
return skill_instructions.get(skill_level, skill_instructions["beginner"])
|
1158
|
+
|
1159
|
+
def _format_error_prompt(self, language: str, skill_instruction: str, error_message: str, code: str) -> str:
|
1160
|
+
"""エラープロンプトをフォーマット"""
|
1161
|
+
if language == "Japanese":
|
1162
|
+
if code:
|
1163
|
+
return f"{skill_instruction}\n\n以下のエラーが発生しました:\n```\n{error_message}\n```\n\nこのコードで:\n```python\n{code}\n```"
|
1164
|
+
else:
|
1165
|
+
return f"{skill_instruction}\n\n以下のエラーが発生しました:\n```\n{error_message}\n```"
|
1166
|
+
else: # English
|
1167
|
+
if code:
|
1168
|
+
return f"{skill_instruction}\n\nI encountered this error:\n```\n{error_message}\n```\n\nIn this code:\n```python\n{code}\n```"
|
1169
|
+
else:
|
1170
|
+
return f"{skill_instruction}\n\nI encountered this error:\n```\n{error_message}\n```"
|
1171
|
+
|
1172
|
+
def _handle_explain_error_failure(self, error: Exception):
|
1173
|
+
"""エラー説明の失敗を処理"""
|
1174
|
+
import traceback
|
1175
|
+
error_details = traceback.format_exc()
|
1176
|
+
logger.error(f"Error in _explain_last_error: {error}\n{error_details}")
|
1177
|
+
messagebox.showerror(
|
1178
|
+
tr("Error"),
|
1179
|
+
f"{tr('Failed to get error information')}: {str(error)}"
|
1180
|
+
)
|
1181
|
+
|
1182
|
+
def _show_settings(self):
|
1183
|
+
"""設定ダイアログを表示"""
|
1184
|
+
from .settings_dialog import SettingsDialog
|
1185
|
+
dialog = SettingsDialog(self)
|
1186
|
+
dialog.grab_set()
|
1187
|
+
self.wait_window(dialog)
|
1188
|
+
|
1189
|
+
if hasattr(dialog, 'settings_changed') and dialog.settings_changed:
|
1190
|
+
self._init_llm()
|
1191
|
+
|
1192
|
+
def _clear_chat(self):
|
1193
|
+
"""チャットをクリア"""
|
1194
|
+
self.messages.clear()
|
1195
|
+
with self._message_lock:
|
1196
|
+
self._current_message = ""
|
1197
|
+
self._update_html(full_reload=True) # クリア時は全体再読み込み
|
1198
|
+
# 履歴もクリア
|
1199
|
+
self._save_chat_history()
|
1200
|
+
|
1201
|
+
def _toggle_context(self):
|
1202
|
+
"""コンテキストの有効/無効を切り替え"""
|
1203
|
+
if self.context_var.get():
|
1204
|
+
if not self.context_manager:
|
1205
|
+
from ..context_manager import ContextManager
|
1206
|
+
self.context_manager = ContextManager()
|
1207
|
+
|
1208
|
+
def _get_chat_history_path(self) -> Path:
|
1209
|
+
"""チャット履歴ファイルのパスを取得"""
|
1210
|
+
workbench = get_workbench()
|
1211
|
+
data_dir = Path(workbench.get_configuration_directory())
|
1212
|
+
llm_dir = data_dir / "llm_assistant"
|
1213
|
+
llm_dir.mkdir(exist_ok=True)
|
1214
|
+
return llm_dir / "chat_history.json"
|
1215
|
+
|
1216
|
+
def _save_chat_history(self):
|
1217
|
+
"""チャット履歴を保存"""
|
1218
|
+
try:
|
1219
|
+
# システムメッセージを除外して保存
|
1220
|
+
messages_to_save = [
|
1221
|
+
{"sender": sender, "text": text}
|
1222
|
+
for sender, text in self.messages
|
1223
|
+
if sender != "system" or not text.startswith("[") # システムの状態メッセージを除外
|
1224
|
+
]
|
1225
|
+
|
1226
|
+
# 最新の100メッセージのみ保存(メモリ節約)
|
1227
|
+
messages_to_save = messages_to_save[-100:]
|
1228
|
+
|
1229
|
+
history_path = self._get_chat_history_path()
|
1230
|
+
with open(history_path, 'w', encoding='utf-8') as f:
|
1231
|
+
json.dump(messages_to_save, f, ensure_ascii=False, indent=2)
|
1232
|
+
|
1233
|
+
except Exception as e:
|
1234
|
+
logger.error(f"Failed to save chat history: {e}")
|
1235
|
+
|
1236
|
+
def _load_chat_history(self):
|
1237
|
+
"""チャット履歴を読み込む"""
|
1238
|
+
try:
|
1239
|
+
history_path = self._get_chat_history_path()
|
1240
|
+
if history_path.exists():
|
1241
|
+
with open(history_path, 'r', encoding='utf-8') as f:
|
1242
|
+
saved_messages = json.load(f)
|
1243
|
+
|
1244
|
+
# 保存されたメッセージを復元
|
1245
|
+
for msg in saved_messages:
|
1246
|
+
self.messages.append((msg["sender"], msg["text"]))
|
1247
|
+
|
1248
|
+
if self.messages:
|
1249
|
+
# 履歴がある場合は、HTMLの準備後にシステムメッセージを追加
|
1250
|
+
self._update_html(full_reload=True) # 履歴読み込み時は全体更新が必要
|
1251
|
+
self.after(300, lambda: self._add_message("system", tr("Previous conversation restored")))
|
1252
|
+
|
1253
|
+
except Exception as e:
|
1254
|
+
logger.error(f"Failed to load chat history: {e}")
|
1255
|
+
|
1256
|
+
def _start_generating_animation(self):
|
1257
|
+
"""生成中のアニメーションを開始(ストリーミングエリアを表示)"""
|
1258
|
+
try:
|
1259
|
+
# 既存のアニメーションがあれば停止
|
1260
|
+
if self._generating_animation_id:
|
1261
|
+
self.after_cancel(self._generating_animation_id)
|
1262
|
+
self._generating_animation_id = None
|
1263
|
+
|
1264
|
+
# ストリーミングフレームを表示(HTMLビューと入力エリアの間に配置)
|
1265
|
+
self.streaming_frame.grid(row=2, column=0, sticky="ew", padx=3, pady=2)
|
1266
|
+
# ストリーミングテキストをクリアして準備
|
1267
|
+
self.streaming_text.config(state=tk.NORMAL)
|
1268
|
+
self.streaming_text.delete("1.0", tk.END)
|
1269
|
+
self.streaming_text.config(state=tk.DISABLED)
|
1270
|
+
|
1271
|
+
except Exception as e:
|
1272
|
+
logger.error(f"Error starting animation: {e}")
|
1273
|
+
|
1274
|
+
def _stop_generating_animation(self):
|
1275
|
+
"""生成中のアニメーションを停止(ストリーミングエリアを非表示)"""
|
1276
|
+
try:
|
1277
|
+
# アニメーションIDをキャンセル
|
1278
|
+
if self._generating_animation_id:
|
1279
|
+
self.after_cancel(self._generating_animation_id)
|
1280
|
+
self._generating_animation_id = None
|
1281
|
+
|
1282
|
+
# ストリーミングフレームを非表示
|
1283
|
+
self.streaming_frame.grid_remove()
|
1284
|
+
|
1285
|
+
except Exception as e:
|
1286
|
+
logger.error(f"Error stopping animation: {e}")
|
1287
|
+
|
1288
|
+
def _on_destroy(self, event):
|
1289
|
+
"""ウィンドウが破棄される時のクリーンアップ"""
|
1290
|
+
# チャット履歴を保存
|
1291
|
+
self._save_chat_history()
|
1292
|
+
|
1293
|
+
if hasattr(self, '_queue_check_id'):
|
1294
|
+
self.after_cancel(self._queue_check_id)
|
1295
|
+
|
1296
|
+
self._stop_generation = True
|
1297
|
+
|
1298
|
+
if self.llm_client:
|
1299
|
+
self.llm_client.shutdown()
|