""" 플로팅 AI 버튼 + 확장(애니메이션) 채팅 모달 요구사항: - 웹의 고객응대/AI assistant처럼 우하단 플로팅 버튼 - 클릭 시 작은 패널에서 큰 채팅 모달로 확장(애니메이션) - 탭 전환 없이 현재 화면 컨텍스트(날짜/열번/편성/호차/타임라인/표시신호/역 전후2개 등) 포함 """ from __future__ import annotations import json from pathlib import Path from typing import Any, Dict, Optional from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve, QRect, QObject, QThread, Signal, QDateTime from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QWidget, QPushButton, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QLineEdit, QTextEdit, QMessageBox, QFrame, QDialogButtonBox, ) from app.ai import get_ai_client from app.ai.maintenance_kb import get_maintenance_kb from app.ui.dialogs.api_key_dialog import APIKeyDialog def _make_json_safe(obj): """json.dumps에서 안전하도록 dict/list/QColor 등을 변환""" if obj is None: return None if isinstance(obj, dict): return {str(k): _make_json_safe(v) for k, v in obj.items()} if isinstance(obj, (list, tuple, set)): return [_make_json_safe(v) for v in obj] if isinstance(obj, QDateTime): try: return obj.toString("yyyy-MM-dd HH:mm:ss") except Exception: return str(obj) # QColor 등 name()이 있는 객체 처리 try: if hasattr(obj, "name") and callable(obj.name): return obj.name() except Exception: pass # Enum 등 value가 있는 경우 try: if hasattr(obj, "value"): return getattr(obj, "value") except Exception: pass return obj class _AIWorker(QObject): finished = Signal(str) failed = Signal(str) def __init__(self, prompt: str): super().__init__() self.prompt = prompt def run(self): try: client = get_ai_client() if not client.is_ready(): raise RuntimeError("AI가 준비되지 않았습니다. 설정에서 API 키를 입력하세요.") resp = client.chat(self.prompt, temperature=0.2) self.finished.emit(resp.content or "") except Exception as e: self.failed.emit(str(e)) class AIFloatingButton(QPushButton): """우하단 플로팅 버튼""" def __init__(self, parent: QWidget): super().__init__("AI", parent) self.setFixedSize(56, 56) self.setCursor(Qt.PointingHandCursor) self.setToolTip("AI Assistant 열기") self.setStyleSheet( """ QPushButton { background-color: #0078d4; color: white; border: none; border-radius: 28px; font-weight: bold; font-size: 14px; } QPushButton:hover { background-color: #1084d8; } QPushButton:pressed { background-color: #006cc1; } """ ) self._is_open = False def set_open_state(self, is_open: bool): self._is_open = bool(is_open) if self._is_open: self.setText("×") self.setToolTip("AI Assistant 닫기") self.setStyleSheet( """ QPushButton { background-color: #2a2a2a; color: #ddd; border: 1px solid #4a4a4a; border-radius: 28px; font-weight: bold; font-size: 18px; } QPushButton:hover { background-color: #333; } QPushButton:pressed { background-color: #222; } """ ) else: self.setText("AI") self.setToolTip("AI Assistant 열기") self.setStyleSheet( """ QPushButton { background-color: #0078d4; color: white; border: none; border-radius: 28px; font-weight: bold; font-size: 14px; } QPushButton:hover { background-color: #1084d8; } QPushButton:pressed { background-color: #006cc1; } """ ) def move_to_bottom_right(self, margin: int = 18): p = self.parentWidget() if not p: return x = p.width() - self.width() - margin y = p.height() - self.height() - margin self.move(max(0, x), max(0, y)) class AIChatDialog(QDialog): """확장되는 채팅 모달(프레임리스)""" def __init__(self, main_window: QWidget, get_panel_ctx_callable): super().__init__(main_window) self._get_panel_ctx = get_panel_ctx_callable self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog) self.setModal(False) self.setAttribute(Qt.WA_TranslucentBackground, True) self._busy = False self._thread: Optional[QThread] = None self._worker: Optional[_AIWorker] = None self._last_payload: Optional[Dict[str, Any]] = None self._build_ui() self.refresh_header() def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) # 실제 카드(둥근 배경) self.card = QFrame() self.card.setStyleSheet( """ QFrame { background: #1f1f1f; border: 1px solid #3a3a3a; border-radius: 14px; } QLabel { color: #ddd; } QLineEdit, QTextEdit { background: #2a2a2a; border: 1px solid #4a4a4a; border-radius: 8px; padding: 8px; color: #eee; } QTextEdit { font-size: 12px; } """ ) card_layout = QVBoxLayout(self.card) card_layout.setContentsMargins(12, 12, 12, 12) card_layout.setSpacing(10) # 헤더 header = QHBoxLayout() self.lbl_title = QLabel("AI Assistant") self.lbl_title.setStyleSheet("font-weight: bold; font-size: 14px; color: #fff;") self.cmb_panel = QComboBox() self.cmb_panel.addItem("Left Panel", "left") self.cmb_panel.addItem("Right Panel", "right") self.cmb_panel.currentIndexChanged.connect(self.refresh_header) self.lbl_ai_status = QLabel("AI: 미준비") self.lbl_ai_status.setStyleSheet("color: #FFAB00;") btn_settings = QPushButton("설정") btn_settings.setFixedHeight(30) btn_settings.clicked.connect(self._open_settings) btn_settings.setStyleSheet( "QPushButton{background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;border-radius:8px;padding:6px 10px;} " "QPushButton:hover{background:#333;}" ) btn_close = QPushButton("×") btn_close.setFixedSize(30, 30) btn_close.clicked.connect(self.close) btn_close.setStyleSheet( "QPushButton{background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;border-radius:8px;font-size:16px;} " "QPushButton:hover{background:#333;}" ) btn_payload = QPushButton("JSON") btn_payload.setFixedHeight(30) btn_payload.clicked.connect(self.show_last_payload) btn_payload.setStyleSheet( "QPushButton{background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;border-radius:8px;padding:6px 10px;} " "QPushButton:hover{background:#333;}" ) header.addWidget(self.lbl_title) header.addStretch() header.addWidget(self.cmb_panel) header.addWidget(self.lbl_ai_status) header.addWidget(btn_payload) header.addWidget(btn_settings) header.addWidget(btn_close) card_layout.addLayout(header) # 기본정보(자동입력 + 수정가능) info_row = QHBoxLayout() self.in_date = QLineEdit() self.in_date.setPlaceholderText("날짜") self.in_trainno = QLineEdit() self.in_trainno.setPlaceholderText("열번") self.in_car = QLineEdit() self.in_car.setPlaceholderText("호차") self.in_formation = QLineEdit() self.in_formation.setPlaceholderText("편성") for w in [self.in_date, self.in_trainno, self.in_car, self.in_formation]: w.setFixedHeight(32) info_row.addWidget(QLabel("날짜")) info_row.addWidget(self.in_date) info_row.addWidget(QLabel("열번")) info_row.addWidget(self.in_trainno) info_row.addWidget(QLabel("호차")) info_row.addWidget(self.in_car) info_row.addWidget(QLabel("편성")) info_row.addWidget(self.in_formation) card_layout.addLayout(info_row) # 추가정보(가이드/툴팁) self.in_extra = QTextEdit() self.in_extra.setFixedHeight(90) self.in_extra.setPlaceholderText( "추가정보 작성 가이드(필요한 것만 기입)\n" "- 발생역: \n" "- 발생시간: \n" "- 발생증상: \n" "- 발생장치: \n" "- 참고: (정상/고장 비교, 다른 열번 같은 역 비교, 특정 조건 구간 등)\n" ) self.in_extra.setToolTip( "예시:\n" "발생역=서면, 발생시간=18:09:12, 발생증상=정위치 불가 추정,\n" "발생장치=ATO/TASC, 참고=타임라인으로 표시한 구간 중심으로 원인 후보 추론" ) card_layout.addWidget(QLabel("추가정보")) card_layout.addWidget(self.in_extra) # 채팅 히스토리 self.txt_chat = QTextEdit() self.txt_chat.setReadOnly(True) self.txt_chat.setPlaceholderText("대화 내용이 여기에 표시됩니다.") self.txt_chat.setMinimumHeight(240) card_layout.addWidget(self.txt_chat, stretch=1) # 입력 + 전송 bottom = QHBoxLayout() self.in_msg = QLineEdit() self.in_msg.setPlaceholderText("요청을 입력하세요. (예: 유사기록 검색 / 특정 조건 구간 분석 / 역명 기준 비교 등)") self.in_msg.setFixedHeight(34) btn_send = QPushButton("전송") btn_send.setFixedHeight(34) btn_send.clicked.connect(self.send_message) btn_send.setStyleSheet( "QPushButton{background:#0078d4;color:white;border:none;border-radius:8px;padding:6px 14px;font-weight:bold;} " "QPushButton:hover{background:#1084d8;} " "QPushButton:pressed{background:#006cc1;}" ) bottom.addWidget(self.in_msg, stretch=1) bottom.addWidget(btn_send) card_layout.addLayout(bottom) root.addWidget(self.card) def _open_settings(self): dlg = APIKeyDialog(self) dlg.settings_changed.connect(self.refresh_header) dlg.exec() def refresh_header(self): # AI 상태 st = get_ai_client().get_status() if st.get("ready"): self.lbl_ai_status.setText(f"AI: {st.get('provider')} / {st.get('model')}") self.lbl_ai_status.setStyleSheet("color: #00D084;") else: self.lbl_ai_status.setText("AI: 미준비") self.lbl_ai_status.setStyleSheet("color: #FFAB00;") # 패널 컨텍스트에서 기본정보 자동 채움(비어있을 때만) ctx = self._get_panel_ctx(self.cmb_panel.currentData(), None) date = (ctx.get("data_date") or "") if isinstance(ctx, dict) else "" record = (ctx.get("record") or {}) if isinstance(ctx, dict) else {} trainno = str(record.get("trainno") or "") if date and not self.in_date.text().strip(): self.in_date.setText(date) if trainno and not self.in_trainno.text().strip(): self.in_trainno.setText(trainno) def _append(self, who: str, text: str): self.txt_chat.append(f"[{who}]\n{text}\n") sb = self.txt_chat.verticalScrollBar() sb.setValue(sb.maximum()) def _build_prompt(self, user_text: str) -> str: panel_id = self.cmb_panel.currentData() ctx = self._get_panel_ctx(panel_id, user_text) or {} basic = { "date": self.in_date.text().strip(), "trainno": self.in_trainno.text().strip(), "car_no": self.in_car.text().strip(), "formation": self.in_formation.text().strip(), } extra = (self.in_extra.toPlainText() or "").strip() # 정비지침서 발췌(유사기록/조건분석 요청에도 도움) kb = get_maintenance_kb() kb_hits = kb.search(" ".join([user_text, extra]), top_k=3) payload = { "basic": basic, "extra": extra, "panel_id": panel_id, "ctx": ctx, "kb_hits": kb_hits, "user_text": user_text, } safe_payload = _make_json_safe(payload) self._last_payload = safe_payload self._auto_save_payload(safe_payload) return ( "당신은 철도 차량 MMI 로그를 분석하는 AI 어시스턴트입니다.\n" "사용자 요청에 대해, 가능한 경우 데이터 근거(필드명/타임라인/역 전후2개)를 들어 설명하세요.\n" "불확실하면 추가로 필요한 데이터/확인 포인트를 질문 형태로 제시하세요.\n\n" f"[기본정보]\n{json.dumps(_make_json_safe(basic), ensure_ascii=False, indent=2)}\n" f"\n[추가정보]\n{extra}\n" f"\n[현재 화면 컨텍스트]\n{json.dumps(_make_json_safe(ctx), ensure_ascii=False, indent=2)}\n" + (f"\n[정비지침서/지식 발췌]\n{json.dumps(_make_json_safe(kb_hits), ensure_ascii=False, indent=2)}\n" if kb_hits else "") + f"\n[사용자 요청]\n{user_text}\n" ) def _auto_save_payload(self, payload: Dict[str, Any]): try: out_dir = Path.home() / ".mmi_analyzer" / "ai_debug" out_dir.mkdir(parents=True, exist_ok=True) path = out_dir / "last_payload.json" path.write_text(json.dumps(_make_json_safe(payload), ensure_ascii=False, indent=2), encoding="utf-8") except Exception: pass def show_last_payload(self): if not self._last_payload: QMessageBox.information(self, "AI", "아직 전송된 payload가 없습니다.") return dlg = QDialog(self) dlg.setWindowTitle("AI Payload (JSON)") dlg.resize(720, 520) lay = QVBoxLayout(dlg) txt = QTextEdit() txt.setReadOnly(True) txt.setText(json.dumps(_make_json_safe(self._last_payload), ensure_ascii=False, indent=2)) lay.addWidget(txt, stretch=1) btns = QDialogButtonBox(QDialogButtonBox.Close) btns.rejected.connect(dlg.reject) btns.accepted.connect(dlg.accept) lay.addWidget(btns) dlg.exec() def send_message(self): text = (self.in_msg.text() or "").strip() if not text: return if self._busy: QMessageBox.information(self, "AI", "이미 요청이 실행 중입니다. 완료 후 다시 시도하세요.") return self.in_msg.clear() self._append("사용자", text) prompt = self._build_prompt(text) self._run(prompt) def _run(self, prompt: str): self._busy = True self._append("AI", "(응답 생성 중...)") self._thread = QThread(self) self._worker = _AIWorker(prompt) self._worker.moveToThread(self._thread) self._thread.started.connect(self._worker.run) self._worker.finished.connect(self._on_done) self._worker.failed.connect(self._on_fail) self._worker.finished.connect(self._thread.quit) self._worker.failed.connect(self._thread.quit) self._thread.finished.connect(self._on_thread_finished) self._thread.start() def _on_done(self, text: str): # 마지막 "(응답 생성 중...)" 줄은 단순히 구분만 하고 새 답변을 추가 self._append("AI", text.strip()) def _on_fail(self, err: str): self._append("AI", f"[오류] {err}") def _on_thread_finished(self): self._thread = None self._worker = None self._busy = False def animate_open(dialog: QDialog, start_rect: QRect, end_rect: QRect, duration_ms: int = 220): dialog.setGeometry(start_rect) dialog.setWindowOpacity(0.0) dialog.show() anim = QPropertyAnimation(dialog, b"geometry", dialog) anim.setDuration(duration_ms) anim.setStartValue(start_rect) anim.setEndValue(end_rect) anim.setEasingCurve(QEasingCurve.OutCubic) anim.start() dialog._open_anim = anim # GC 방지 fade = QPropertyAnimation(dialog, b"windowOpacity", dialog) fade.setDuration(max(120, duration_ms)) fade.setStartValue(0.0) fade.setEndValue(1.0) fade.setEasingCurve(QEasingCurve.OutCubic) fade.start() dialog._open_fade = fade def animate_close(dialog: QDialog, start_rect: QRect, end_rect: QRect, duration_ms: int = 180): anim = QPropertyAnimation(dialog, b"geometry", dialog) anim.setDuration(duration_ms) anim.setStartValue(start_rect) anim.setEndValue(end_rect) anim.setEasingCurve(QEasingCurve.InCubic) anim.finished.connect(dialog.close) anim.start() dialog._close_anim = anim fade = QPropertyAnimation(dialog, b"windowOpacity", dialog) fade.setDuration(max(100, duration_ms)) fade.setStartValue(dialog.windowOpacity()) fade.setEndValue(0.0) fade.setEasingCurve(QEasingCurve.InCubic) fade.start() dialog._close_fade = fade