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.
Files changed (27) hide show
  1. thonny_codemate-0.1.0.dist-info/METADATA +307 -0
  2. thonny_codemate-0.1.0.dist-info/RECORD +27 -0
  3. thonny_codemate-0.1.0.dist-info/WHEEL +5 -0
  4. thonny_codemate-0.1.0.dist-info/licenses/LICENSE +21 -0
  5. thonny_codemate-0.1.0.dist-info/top_level.txt +1 -0
  6. thonnycontrib/__init__.py +1 -0
  7. thonnycontrib/thonny_codemate/__init__.py +397 -0
  8. thonnycontrib/thonny_codemate/api.py +154 -0
  9. thonnycontrib/thonny_codemate/context_manager.py +296 -0
  10. thonnycontrib/thonny_codemate/external_providers.py +714 -0
  11. thonnycontrib/thonny_codemate/i18n.py +506 -0
  12. thonnycontrib/thonny_codemate/llm_client.py +841 -0
  13. thonnycontrib/thonny_codemate/message_virtualization.py +136 -0
  14. thonnycontrib/thonny_codemate/model_manager.py +515 -0
  15. thonnycontrib/thonny_codemate/performance_monitor.py +141 -0
  16. thonnycontrib/thonny_codemate/prompts.py +102 -0
  17. thonnycontrib/thonny_codemate/ui/__init__.py +1 -0
  18. thonnycontrib/thonny_codemate/ui/chat_view.py +687 -0
  19. thonnycontrib/thonny_codemate/ui/chat_view_html.py +1299 -0
  20. thonnycontrib/thonny_codemate/ui/custom_prompt_dialog.py +175 -0
  21. thonnycontrib/thonny_codemate/ui/markdown_renderer.py +484 -0
  22. thonnycontrib/thonny_codemate/ui/model_download_dialog.py +355 -0
  23. thonnycontrib/thonny_codemate/ui/settings_dialog.py +1218 -0
  24. thonnycontrib/thonny_codemate/utils/__init__.py +25 -0
  25. thonnycontrib/thonny_codemate/utils/constants.py +138 -0
  26. thonnycontrib/thonny_codemate/utils/error_messages.py +92 -0
  27. 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()