281 lines
11 KiB
Python
281 lines
11 KiB
Python
from PySide6.QtWidgets import (
|
|
QMainWindow,
|
|
QWidget,
|
|
QVBoxLayout,
|
|
QSplitter,
|
|
QToolBar,
|
|
QStatusBar,
|
|
QLabel,
|
|
)
|
|
from PySide6.QtCore import Qt, QRect
|
|
from PySide6.QtGui import QAction
|
|
|
|
from app.ui.analysis_panel import AnalysisPanel
|
|
from app.core.sync_controller import sync_manager
|
|
from app.ui.components.toggle_button import ToggleButton
|
|
from app.ui.dialogs.api_key_dialog import APIKeyDialog
|
|
from app.core.settings import get_settings_manager
|
|
from app.ui.widgets.ai_floating_chat import AIFloatingButton, AIChatDialog, animate_open, animate_close
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("SL200 AI Smart Analyzer")
|
|
self.resize(1800, 1000)
|
|
|
|
self.setup_ui()
|
|
self.setup_menubar()
|
|
self.setup_toolbar()
|
|
|
|
# 패널 데이터 로드 시 날짜 비교를 위한 시그널 연결
|
|
self.left_panel.data_loaded.connect(self.check_sync_availability)
|
|
self.right_panel.data_loaded.connect(self.check_sync_availability)
|
|
|
|
# AI 클라이언트 초기화
|
|
self._init_ai_client()
|
|
|
|
def setup_ui(self):
|
|
central = QWidget()
|
|
self.setCentralWidget(central)
|
|
layout = QVBoxLayout(central)
|
|
|
|
# 좌우 분할
|
|
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
|
|
# 왼쪽/오른쪽 패널 생성 (ID 부여)
|
|
self.left_panel = AnalysisPanel("left")
|
|
self.right_panel = AnalysisPanel("right")
|
|
|
|
splitter.addWidget(self.left_panel)
|
|
splitter.addWidget(self.right_panel)
|
|
splitter.setSizes([900, 900]) # 1:1 비율
|
|
|
|
layout.addWidget(splitter)
|
|
|
|
self.status_bar = QStatusBar()
|
|
self.setStatusBar(self.status_bar)
|
|
|
|
# 플로팅 AI 버튼
|
|
self._ai_button = AIFloatingButton(central)
|
|
self._ai_button.clicked.connect(self._open_ai_chat)
|
|
self._ai_button.show()
|
|
self._ai_chat: AIChatDialog | None = None
|
|
self._ai_button.move_to_bottom_right()
|
|
|
|
def resizeEvent(self, event):
|
|
super().resizeEvent(event)
|
|
try:
|
|
if hasattr(self, "_ai_button") and self._ai_button:
|
|
self._ai_button.move_to_bottom_right()
|
|
except Exception:
|
|
pass
|
|
|
|
def setup_toolbar(self):
|
|
toolbar = QToolBar("Main Toolbar")
|
|
self.addToolBar(toolbar)
|
|
|
|
# 커스텀 토글 버튼
|
|
self.sync_toggle = ToggleButton(width=60, active_color="#00D084") # 녹색
|
|
self.sync_toggle.toggled.connect(self.on_sync_toggled)
|
|
self.sync_toggle.setEnabled(False) # 초기에는 비활성화
|
|
|
|
toolbar.addWidget(QLabel(" SYNC MODE: "))
|
|
toolbar.addWidget(self.sync_toggle)
|
|
|
|
# 상태 라벨
|
|
self.sync_status_label = QLabel(" (데이터 없음)")
|
|
self.sync_status_label.setStyleSheet("color: #888;")
|
|
toolbar.addWidget(self.sync_status_label)
|
|
|
|
def setup_menubar(self):
|
|
"""메뉴바 설정"""
|
|
menubar = self.menuBar()
|
|
|
|
# 파일 메뉴
|
|
menubar.addMenu("파일(&F)")
|
|
|
|
# 설정 메뉴
|
|
settings_menu = menubar.addMenu("설정(&S)")
|
|
|
|
# AI API 키 설정
|
|
ai_settings_action = QAction("🔑 AI API 키 설정...", self)
|
|
ai_settings_action.setShortcut("Ctrl+Shift+A")
|
|
ai_settings_action.triggered.connect(self.show_api_key_dialog)
|
|
settings_menu.addAction(ai_settings_action)
|
|
|
|
# 도움말 메뉴
|
|
menubar.addMenu("도움말(&H)")
|
|
|
|
def show_api_key_dialog(self):
|
|
"""API 키 설정 다이얼로그 표시"""
|
|
dialog = APIKeyDialog(self)
|
|
dialog.settings_changed.connect(self._on_ai_settings_changed)
|
|
dialog.exec()
|
|
|
|
def _on_ai_settings_changed(self):
|
|
"""AI 설정이 변경되었을 때"""
|
|
self.status_bar.showMessage("AI 설정이 저장되었습니다.", 3000)
|
|
self._init_ai_client()
|
|
|
|
def check_sync_availability(self):
|
|
"""두 패널의 데이터 날짜가 같은지 확인하여 Sync 버튼 활성화/비활성화"""
|
|
left_date = self.left_panel.get_data_date()
|
|
right_date = self.right_panel.get_data_date()
|
|
|
|
# 둘 다 데이터가 있는 경우에만 비교
|
|
if left_date and right_date:
|
|
if left_date == right_date:
|
|
# 날짜가 같으면 Sync 활성화
|
|
self.sync_toggle.setEnabled(True)
|
|
self.sync_status_label.setText(f" ({left_date})")
|
|
self.sync_status_label.setStyleSheet("color: #00D084;")
|
|
else:
|
|
# 날짜가 다르면 Sync 비활성화
|
|
self.sync_toggle.setEnabled(False)
|
|
self.sync_toggle.setChecked(False) # 자동으로 끔
|
|
self.sync_status_label.setText(f" (날짜 불일치: {left_date} ≠ {right_date})")
|
|
self.sync_status_label.setStyleSheet("color: #FF5252;")
|
|
elif left_date or right_date:
|
|
# 한쪽만 데이터가 있음
|
|
self.sync_toggle.setEnabled(False)
|
|
date_info = left_date or right_date
|
|
self.sync_status_label.setText(f" ({date_info} - 상대 패널 없음)")
|
|
self.sync_status_label.setStyleSheet("color: #888;")
|
|
else:
|
|
# 둘 다 데이터 없음
|
|
self.sync_toggle.setEnabled(False)
|
|
self.sync_status_label.setText(" (데이터 없음)")
|
|
self.sync_status_label.setStyleSheet("color: #888;")
|
|
|
|
def on_sync_toggled(self, checked):
|
|
"""동기화 모드 변경 시 동작"""
|
|
sync_manager.is_sync_enabled = checked
|
|
|
|
if checked:
|
|
# [SYNC ON]
|
|
# 1. 오른쪽 패널의 파일 로드 버튼 숨김
|
|
self.right_panel.set_slave_mode(True)
|
|
|
|
# 2. 왼쪽 패널의 데이터가 있다면 오른쪽으로 복사
|
|
if self.left_panel.raw_data:
|
|
self.right_panel.receive_shared_data(self.left_panel.raw_data)
|
|
|
|
self.status_bar.showMessage("Sync ON: 오른쪽 패널이 왼쪽 데이터와 동기화됩니다.")
|
|
else:
|
|
# [SYNC OFF]
|
|
self.right_panel.set_slave_mode(False)
|
|
self.status_bar.showMessage("Sync OFF: 독립 분석 모드")
|
|
|
|
def _init_ai_client(self):
|
|
"""저장된 설정으로 AI 클라이언트 초기화(가능할 때만)"""
|
|
try:
|
|
from app.ai import get_ai_client, AIProviderType
|
|
|
|
settings = get_settings_manager().ai_settings
|
|
current_provider = settings.get_current_provider()
|
|
|
|
if not current_provider:
|
|
return
|
|
|
|
api_key = settings.get_api_key(current_provider)
|
|
model = settings.get_model(current_provider)
|
|
|
|
if not api_key:
|
|
return
|
|
|
|
client = get_ai_client()
|
|
provider_type = AIProviderType(current_provider)
|
|
if client.set_provider(provider_type, api_key, model):
|
|
st = client.get_status()
|
|
self.status_bar.showMessage(f"AI 준비: {st.get('provider')} / {st.get('model')}", 3000)
|
|
except Exception as e:
|
|
print(f"AI 클라이언트 초기화 실패: {e}")
|
|
|
|
def _get_panel_ctx(self, panel_id: str, user_query: str | None = None):
|
|
panel = self.right_panel if panel_id == "right" else self.left_panel
|
|
ctx = panel.get_ai_context()
|
|
|
|
# 질의에 따라 추가 분석 결과를 포함(프롬프트 품질 개선)
|
|
if user_query:
|
|
q = user_query.lower()
|
|
if ("pg3" in q or "pg3-2" in q) and ("역" in user_query):
|
|
try:
|
|
ctx.setdefault("analysis", {})
|
|
ctx["analysis"]["pg3_2_per_station"] = panel.compute_pg_at_station("PG3-2")
|
|
except Exception:
|
|
pass
|
|
|
|
return ctx
|
|
|
|
def _is_qobject_alive(self, obj) -> bool:
|
|
"""PySide6 객체가 이미 delete된 상태인지 안전하게 판별"""
|
|
if obj is None:
|
|
return False
|
|
try:
|
|
import shiboken6 # type: ignore
|
|
return bool(shiboken6.isValid(obj))
|
|
except Exception:
|
|
# shiboken6이 없거나, 일부 환경에서 import 실패 시 런타임 호출로 체크
|
|
try:
|
|
obj.objectName()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def _on_ai_chat_destroyed(self, *_args):
|
|
# 삭제된 객체 참조 제거(다음 클릭에서 RuntimeError 방지)
|
|
self._ai_chat = None
|
|
self._ai_button.set_open_state(False)
|
|
|
|
def _open_ai_chat(self):
|
|
# 열려 있으면: 웹처럼 버튼(X)로 닫기 동작
|
|
if self._ai_chat and self._is_qobject_alive(self._ai_chat) and self._ai_chat.isVisible():
|
|
btn = self._ai_button
|
|
start_rect = self._ai_chat.geometry()
|
|
# 닫힘 애니메이션은 버튼 위치로 축소
|
|
btn_pos = btn.mapTo(self, btn.rect().topLeft())
|
|
end_rect = QRect(btn_pos.x(), btn_pos.y(), btn.width(), btn.height())
|
|
animate_close(self._ai_chat, start_rect, end_rect, duration_ms=180)
|
|
return
|
|
|
|
# 레퍼런스는 남아있지만 이미 삭제된 경우 정리
|
|
if self._ai_chat and not self._is_qobject_alive(self._ai_chat):
|
|
self._ai_chat = None
|
|
self._ai_button.set_open_state(False)
|
|
|
|
self._ai_chat = AIChatDialog(self, self._get_panel_ctx)
|
|
self._ai_chat.setAttribute(Qt.WA_DeleteOnClose, True)
|
|
self._ai_chat.destroyed.connect(self._on_ai_chat_destroyed)
|
|
self._ai_button.set_open_state(True)
|
|
|
|
# 버튼 위치 기준으로 확장 애니메이션
|
|
btn = self._ai_button
|
|
# 메인윈도우 좌표로 변환
|
|
btn_top_left = btn.mapTo(self, btn.rect().topLeft())
|
|
start_rect = QRect(btn_top_left.x(), btn_top_left.y(), btn.width(), btn.height())
|
|
|
|
# 최종 크기: 버튼을 가리지 않도록 '버튼 위로' 배치(가능하면)
|
|
w, h = 560, 680
|
|
gap = 10
|
|
margin = 14
|
|
|
|
# 1) 우선: 버튼 위로(오른쪽 정렬)
|
|
end_x = btn_top_left.x() + btn.width() - w
|
|
end_y = btn_top_left.y() - h - gap
|
|
|
|
# 위로 배치 불가능하면: 버튼 왼쪽으로
|
|
if end_y < margin:
|
|
end_y = max(margin, btn_top_left.y() + btn.height() - h) # 세로는 버튼과 겹치지 않게 최대한 맞춤
|
|
end_x = btn_top_left.x() - w - gap
|
|
|
|
# 그래도 화면 밖이면: 화면 안으로 클램프(버튼 가림 최소화)
|
|
end_x = max(margin, min(end_x, self.width() - w - margin))
|
|
end_y = max(margin, min(end_y, self.height() - h - margin))
|
|
|
|
end_rect = QRect(end_x, end_y, w, h)
|
|
|
|
animate_open(self._ai_chat, start_rect, end_rect, duration_ms=280)
|
|
|
|
|