495 lines
18 KiB
Python
495 lines
18 KiB
Python
"""
|
||
플로팅 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
|
||
|
||
|