AI_MMI_Analyser/app/ui/widgets/ai_floating_chat.py

495 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
플로팅 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