CLFGUI / src /clfgui /gui /main_window.py
smlflg's picture
Initial public upload from Projekte/CLFGUI
a4514b4 verified
Raw History Blame Contribute Delete
27.2 kB
from __future__ import annotations
from functools import partial
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QLayout,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QTextEdit,
QVBoxLayout,
QWidget,
)
from clfgui.kernel import Job, JobSnapshot, LocalAgentKernel, Phase, SQLiteStatusStore
from .paths import qstandard_db_path
STATUS_LABELS = {
"running": "Läuft",
"waiting_decision": "Human-Gate",
"awaiting_approval": "Ergebnisfreigabe",
"approved": "Freigegeben",
"rejected": "Abgelehnt",
"blocked": "Blockiert",
"pending": "Ausstehend",
"waiting": "Wartet",
"completed": "Fertig",
"failed": "Fehler",
}
PHASE_LABELS = {
"plan": "Plan",
"research": "Research",
"code": "Code",
"test": "Test",
"verify": "Verify",
"result": "Ergebnis",
}
class MainWindow(QMainWindow):
def __init__(self, kernel: LocalAgentKernel | None = None) -> None:
super().__init__()
self.kernel = kernel or LocalAgentKernel(SQLiteStatusStore(qstandard_db_path()))
self.active_job_id: int | None = None
self._selected_decision_id: int | None = None
self._selected_decision_option: str | None = None
self.setWindowTitle("CLFGUI")
self.resize(1360, 820)
self.setMinimumSize(1120, 700)
self._build_ui()
self._apply_style()
self.refresh()
self._timer = QTimer(self)
self._timer.timeout.connect(self.refresh)
self._timer.start(1000)
def _build_ui(self) -> None:
root = QWidget()
root.setObjectName("appRoot")
root_layout = QHBoxLayout(root)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
root_layout.addWidget(self._build_sidebar())
root_layout.addWidget(self._build_workspace(), 1)
self.setCentralWidget(root)
def _build_sidebar(self) -> QWidget:
sidebar = QFrame()
sidebar.setObjectName("sidebar")
sidebar.setFixedWidth(342)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(22, 22, 22, 22)
layout.setSpacing(18)
title = QLabel("Owner Cockpit")
title.setObjectName("appTitle")
self.status_badge = QLabel("Bereit")
self.status_badge.setObjectName("statusBadge")
self.status_badge.setProperty("state", "idle")
self.status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.intent_input = QTextEdit()
self.intent_input.setObjectName("intentInput")
self.intent_input.setAcceptRichText(False)
self.intent_input.setPlaceholderText("Ich will ...")
self.intent_input.setFixedHeight(116)
self.submit_button = QPushButton("Auftrag starten")
self.submit_button.setObjectName("primaryButton")
self.submit_button.clicked.connect(self._submit_intent)
jobs_title = QLabel("Aufträge")
jobs_title.setObjectName("sidebarSection")
self.jobs_content = QWidget()
self.jobs_content.setObjectName("jobsContent")
self.jobs_layout = QVBoxLayout(self.jobs_content)
self.jobs_layout.setContentsMargins(0, 0, 0, 0)
self.jobs_layout.setSpacing(8)
jobs_scroll = QScrollArea()
jobs_scroll.setObjectName("jobsScroll")
jobs_scroll.setWidgetResizable(True)
jobs_scroll.setFrameShape(QFrame.Shape.NoFrame)
jobs_scroll.setWidget(self.jobs_content)
layout.addWidget(title)
layout.addWidget(self.status_badge)
layout.addWidget(self.intent_input)
layout.addWidget(self.submit_button)
layout.addSpacing(6)
layout.addWidget(jobs_title)
layout.addWidget(jobs_scroll, 1)
return sidebar
def _build_workspace(self) -> QWidget:
workspace = QWidget()
workspace.setObjectName("workspace")
layout = QVBoxLayout(workspace)
layout.setContentsMargins(24, 22, 24, 22)
layout.setSpacing(18)
top = QHBoxLayout()
top.setSpacing(16)
title_group = QVBoxLayout()
title_group.setSpacing(4)
page_title = QLabel("Aktiver Auftrag")
page_title.setObjectName("pageTitle")
self.header_context = QLabel("Kein Auftrag aktiv.")
self.header_context.setObjectName("headerContext")
self.header_context.setWordWrap(True)
title_group.addWidget(page_title)
title_group.addWidget(self.header_context)
self.plan_status = QLabel("Human-Gates: keine offen")
self.plan_status.setObjectName("topMeta")
top.addLayout(title_group, 1)
top.addWidget(self.plan_status, 0, Qt.AlignmentFlag.AlignTop)
body = QHBoxLayout()
body.setSpacing(18)
body.addWidget(self._build_plan_panel(), 1)
body.addWidget(self._build_action_panel())
layout.addLayout(top)
layout.addLayout(body, 1)
return workspace
def _build_plan_panel(self) -> QWidget:
panel = self._panel("planPanel")
layout = QVBoxLayout(panel)
layout.setContentsMargins(20, 18, 20, 20)
layout.setSpacing(14)
layout.addWidget(self._section_title("Laufender Plan"))
self.phase_content = QWidget()
self.phase_content.setObjectName("phaseContent")
self.phase_layout = QVBoxLayout(self.phase_content)
self.phase_layout.setContentsMargins(0, 0, 0, 0)
self.phase_layout.setSpacing(10)
phase_scroll = QScrollArea()
phase_scroll.setObjectName("phaseScroll")
phase_scroll.setWidgetResizable(True)
phase_scroll.setFrameShape(QFrame.Shape.NoFrame)
phase_scroll.setWidget(self.phase_content)
layout.addWidget(phase_scroll, 1)
return panel
def _build_action_panel(self) -> QWidget:
panel = QWidget()
panel.setObjectName("actionColumn")
panel.setFixedWidth(402)
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(18)
layout.addWidget(self._build_decision_panel(), 1)
layout.addWidget(self._build_result_panel(), 1)
return panel
def _build_decision_panel(self) -> QWidget:
panel = self._panel("decisionPanel")
layout = QVBoxLayout(panel)
layout.setContentsMargins(20, 18, 20, 20)
layout.setSpacing(14)
layout.addWidget(self._section_title("Entscheidung"))
self.decision_content = QWidget()
self.decision_content_layout = QVBoxLayout(self.decision_content)
self.decision_content_layout.setContentsMargins(0, 0, 0, 0)
self.decision_content_layout.setSpacing(12)
layout.addWidget(self.decision_content, 1)
return panel
def _build_result_panel(self) -> QWidget:
panel = self._panel("resultPanel")
layout = QVBoxLayout(panel)
layout.setContentsMargins(20, 18, 20, 20)
layout.setSpacing(14)
layout.addWidget(self._section_title("Ergebnis"))
self.result_text = QTextEdit()
self.result_text.setObjectName("resultText")
self.result_text.setReadOnly(True)
self.result_text.setAcceptRichText(False)
self.result_text.setPlaceholderText("Noch kein Ergebnis vorhanden.")
button_row = QHBoxLayout()
button_row.setSpacing(10)
self.approve_button = QPushButton("Freigeben")
self.approve_button.setObjectName("primaryButton")
self.approve_button.clicked.connect(partial(self._approve_result, True))
self.reject_button = QPushButton("Ablehnen")
self.reject_button.setObjectName("secondaryButton")
self.reject_button.clicked.connect(partial(self._approve_result, False))
button_row.addWidget(self.approve_button)
button_row.addWidget(self.reject_button)
layout.addWidget(self.result_text, 1)
layout.addLayout(button_row)
return panel
@staticmethod
def _panel(name: str) -> QFrame:
panel = QFrame()
panel.setObjectName(name)
panel.setFrameShape(QFrame.Shape.NoFrame)
return panel
@staticmethod
def _section_title(text: str) -> QLabel:
label = QLabel(text)
label.setObjectName("sectionTitle")
return label
def _apply_style(self) -> None:
self.setStyleSheet(
"""
#appRoot {
background: #eef1f4;
}
QWidget {
color: #18202a;
font-size: 13px;
}
QLabel {
background: transparent;
}
#sidebar {
background: #151d27;
border-right: 1px solid #25313e;
}
#workspace {
background: #eef1f4;
}
#appTitle {
color: #f8fafc;
font-size: 24px;
font-weight: 800;
}
#sidebarSection {
color: #aeb8c4;
font-size: 12px;
font-weight: 800;
letter-spacing: 0px;
}
#pageTitle {
color: #18202a;
font-size: 24px;
font-weight: 800;
}
#headerContext {
color: #637080;
font-size: 13px;
}
#topMeta {
background: #ffffff;
color: #56616f;
border: 1px solid #d7dde5;
border-radius: 10px;
padding: 8px 12px;
font-weight: 700;
}
#statusBadge {
background: #263241;
color: #dce3ea;
border: 1px solid #334253;
border-radius: 10px;
padding: 9px 12px;
font-weight: 800;
}
#statusBadge[state="waiting_decision"], #statusBadge[state="waiting"] {
background: #382e16;
border-color: #9c7a25;
color: #f7d677;
}
#statusBadge[state="awaiting_approval"], #statusBadge[state="running"] {
background: #173044;
border-color: #34708f;
color: #a6d8ef;
}
#statusBadge[state="approved"] {
background: #173526;
border-color: #2f7b52;
color: #99ddb6;
}
#statusBadge[state="blocked"], #statusBadge[state="rejected"], #statusBadge[state="failed"] {
background: #3e1e1e;
border-color: #9d4a40;
color: #f2b0a7;
}
#intentInput {
background: #0f1721;
color: #f4f7fb;
border: 1px solid #2a3847;
border-radius: 12px;
padding: 10px;
selection-background-color: #2e6b77;
}
#intentInput::placeholder {
color: #758293;
}
#resultText {
background: #f8fafc;
color: #202a35;
border: 1px solid #d8dee6;
border-radius: 12px;
padding: 10px;
selection-background-color: #cde3ec;
}
#primaryButton {
background: #2f7783;
color: #ffffff;
border: 1px solid #286773;
border-radius: 12px;
padding: 10px 14px;
min-height: 34px;
font-weight: 800;
}
#primaryButton:hover:!disabled {
background: #286b76;
}
#secondaryButton {
background: #ffffff;
color: #2e3946;
border: 1px solid #cbd3dc;
border-radius: 12px;
padding: 10px 14px;
min-height: 34px;
font-weight: 800;
}
#secondaryButton:hover:!disabled {
background: #f4f7fa;
}
QPushButton:disabled {
background: #e4e8ed;
border: 1px solid #d3d9e1;
color: #9aa5b2;
}
#jobsScroll, #phaseScroll {
background: transparent;
border: 0;
}
#jobsContent, #phaseContent, #actionColumn {
background: transparent;
}
#jobButton {
background: #1c2734;
color: #c7d0dc;
border: 1px solid #2c3948;
border-radius: 12px;
padding: 11px 12px;
text-align: left;
font-weight: 700;
}
#jobButton:hover {
background: #243244;
}
#jobButton[selected="true"] {
background: #eaf7fa;
color: #14313a;
border-color: #72aeb8;
}
#planPanel, #decisionPanel, #resultPanel {
background: #ffffff;
border: 1px solid #d9dfe7;
border-radius: 18px;
}
#sectionTitle {
color: #18202a;
font-size: 16px;
font-weight: 800;
}
#phaseRow {
background: #f8fafc;
border: 1px solid #e0e5ec;
border-radius: 14px;
}
#phaseRow[active="true"] {
background: #fff8e8;
border-color: #e5be5f;
}
#phaseName {
color: #18202a;
font-weight: 800;
font-size: 14px;
}
#phaseSummary {
color: #5b6775;
font-size: 13px;
}
#statusPill {
border-radius: 10px;
padding: 5px 9px;
font-weight: 800;
font-size: 12px;
}
#statusPill[state="pending"] {
background: #edf1f5;
color: #657181;
}
#statusPill[state="waiting"], #statusPill[state="waiting_decision"] {
background: #fff0c6;
color: #735300;
}
#statusPill[state="completed"], #statusPill[state="approved"] {
background: #daf3e3;
color: #1f6339;
}
#statusPill[state="running"], #statusPill[state="awaiting_approval"] {
background: #dceff7;
color: #22566d;
}
#statusPill[state="blocked"], #statusPill[state="failed"], #statusPill[state="rejected"] {
background: #f9ddd8;
color: #84372c;
}
#decisionTitle {
color: #18202a;
font-size: 16px;
font-weight: 800;
}
#bodyText {
color: #526070;
line-height: 1.35;
}
#optionButton {
background: #f7f9fb;
color: #26313d;
border: 1px solid #d9e0e8;
border-radius: 12px;
padding: 10px 12px;
text-align: left;
font-weight: 700;
}
#optionButton:hover {
background: #eef5f7;
}
#optionButton[selected="true"] {
background: #e5f4f7;
color: #14353c;
border-color: #5ca2ad;
}
#decisionNote {
background: #f8fafc;
color: #202a35;
border: 1px solid #d8dee6;
border-radius: 12px;
padding: 9px;
}
QScrollBar:vertical {
background: transparent;
width: 10px;
margin: 0;
}
QScrollBar::handle:vertical {
background: #c6ced8;
border-radius: 5px;
min-height: 36px;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0;
}
"""
)
def _submit_intent(self) -> None:
try:
job_id = self.kernel.submit_intent(self.intent_input.toPlainText())
except ValueError as exc:
QMessageBox.warning(self, "Auftrag fehlt", str(exc))
return
self.active_job_id = job_id
self.intent_input.clear()
self.refresh()
def _select_job(self, job_id: int) -> None:
self.active_job_id = job_id
self.refresh()
def _select_decision_option(self, decision_id: int, option: str) -> None:
self._selected_decision_id = decision_id
self._selected_decision_option = option
for button in self.decision_content.findChildren(QPushButton):
if button.objectName() != "optionButton":
continue
selected = button.property("decision_id") == decision_id and button.property("option") == option
button.setProperty("selected", "true" if selected else "false")
self._refresh_widget_style(button)
def _answer_decision(self) -> None:
if self._selected_decision_id is None or self._selected_decision_option is None:
QMessageBox.warning(self, "Entscheidung fehlt", "Bitte eine Option auswählen.")
return
self.kernel.answer_decision(
self._selected_decision_id,
self._selected_decision_option,
self.decision_note.toPlainText(),
)
self._selected_decision_id = None
self._selected_decision_option = None
self.refresh()
def _approve_result(self, approved: bool) -> None:
if self.active_job_id is None:
return
try:
self.kernel.approve_result(self.active_job_id, approved)
except KeyError:
QMessageBox.information(self, "Kein Ergebnis", "Für diesen Auftrag liegt noch kein Ergebnis vor.")
return
self.refresh()
def refresh(self) -> None:
self._refresh_jobs()
snapshot = self._active_snapshot()
self._refresh_header(snapshot)
self._refresh_plan(snapshot)
self._refresh_decisions(snapshot)
self._refresh_results(snapshot)
def _refresh_jobs(self) -> None:
jobs = self.kernel.list_jobs()
if self.active_job_id is None and jobs:
self.active_job_id = jobs[0].id
self._clear_layout(self.jobs_layout)
for job in jobs:
self.jobs_layout.addWidget(self._job_button(job))
self.jobs_layout.addStretch(1)
def _job_button(self, job: Job) -> QPushButton:
status = STATUS_LABELS.get(job.status, job.status)
intent = job.intent if len(job.intent) <= 48 else f"{job.intent[:45]}..."
button = QPushButton(f"#{job.id} · {status}\n{intent}")
button.setObjectName("jobButton")
button.setProperty("selected", "true" if job.id == self.active_job_id else "false")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(partial(self._select_job, job.id))
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
return button
def _active_snapshot(self) -> JobSnapshot | None:
if self.active_job_id is None:
return None
try:
return self.kernel.get_job_status(self.active_job_id)
except KeyError:
self.active_job_id = None
return None
def _refresh_header(self, snapshot: JobSnapshot | None) -> None:
if snapshot is None:
self.header_context.setText("Kein Auftrag aktiv.")
self.status_badge.setText("Bereit")
self._set_status_state("idle")
return
status = STATUS_LABELS.get(snapshot.job.status, snapshot.job.status)
self.status_badge.setText(status)
self._set_status_state(snapshot.job.status)
self.header_context.setText(f"#{snapshot.job.id} · {snapshot.job.intent}")
def _refresh_plan(self, snapshot: JobSnapshot | None) -> None:
self._clear_layout(self.phase_layout)
if snapshot is None:
self.plan_status.setText("Human-Gates: keine offen")
self.phase_layout.addWidget(self._empty_label("Kein Auftrag ausgewählt."))
self.phase_layout.addStretch(1)
return
open_decisions = [decision for decision in snapshot.decisions if decision.status == "open"]
gate_text = f"{len(open_decisions)} offen" if open_decisions else "keine offen"
self.plan_status.setText(f"Human-Gates: {gate_text}")
for phase in snapshot.phases:
self.phase_layout.addWidget(self._phase_row(phase))
self.phase_layout.addStretch(1)
def _phase_row(self, phase: Phase) -> QWidget:
row = QFrame()
row.setObjectName("phaseRow")
row.setProperty("active", "true" if phase.status in {"running", "waiting"} else "false")
layout = QHBoxLayout(row)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(14)
text_group = QVBoxLayout()
text_group.setSpacing(3)
name = QLabel(PHASE_LABELS.get(phase.name, phase.name))
name.setObjectName("phaseName")
summary = QLabel(phase.summary or "Noch keine Zusammenfassung.")
summary.setObjectName("phaseSummary")
summary.setWordWrap(True)
text_group.addWidget(name)
text_group.addWidget(summary)
status = QLabel(STATUS_LABELS.get(phase.status, phase.status))
status.setObjectName("statusPill")
status.setProperty("state", phase.status)
status.setAlignment(Qt.AlignmentFlag.AlignCenter)
status.setMinimumWidth(112)
layout.addLayout(text_group, 1)
layout.addWidget(status, 0, Qt.AlignmentFlag.AlignTop)
return row
def _refresh_decisions(self, snapshot: JobSnapshot | None) -> None:
self._clear_layout(self.decision_content_layout)
self._selected_decision_id = None
self._selected_decision_option = None
if snapshot is None:
self.decision_content_layout.addWidget(self._empty_label("Kein Auftrag ausgewählt."))
self.decision_content_layout.addStretch(1)
return
open_decisions = [decision for decision in snapshot.decisions if decision.status == "open"]
if not open_decisions:
self.decision_content_layout.addWidget(self._empty_label("Keine offenen Human-Gates."))
self.decision_content_layout.addStretch(1)
return
decision = open_decisions[0]
title = QLabel(decision.title)
title.setObjectName("decisionTitle")
title.setWordWrap(True)
context = QLabel(decision.context)
context.setWordWrap(True)
context.setObjectName("bodyText")
self.decision_content_layout.addWidget(title)
self.decision_content_layout.addWidget(context)
for index, option in enumerate(decision.options):
button = QPushButton(option)
button.setObjectName("optionButton")
button.setProperty("decision_id", decision.id)
button.setProperty("option", option)
button.setProperty("selected", "true" if index == 0 else "false")
button.setCheckable(False)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(partial(self._select_decision_option, decision.id, option))
self.decision_content_layout.addWidget(button)
if index == 0:
self._selected_decision_id = decision.id
self._selected_decision_option = option
self.decision_note = QTextEdit()
self.decision_note.setObjectName("decisionNote")
self.decision_note.setAcceptRichText(False)
self.decision_note.setPlaceholderText("Optionaler Hinweis")
self.decision_note.setFixedHeight(86)
submit = QPushButton("Antwort senden")
submit.setObjectName("primaryButton")
submit.clicked.connect(self._answer_decision)
self.decision_content_layout.addWidget(self.decision_note)
self.decision_content_layout.addWidget(submit)
self.decision_content_layout.addStretch(1)
def _refresh_results(self, snapshot: JobSnapshot | None) -> None:
if snapshot is None or snapshot.result is None:
self.result_text.clear()
self.approve_button.setEnabled(False)
self.reject_button.setEnabled(False)
return
result = snapshot.result
decisions = "\n".join(f"- {item}" for item in result.decisions) or "- Keine"
next_steps = "\n".join(f"- {item}" for item in result.next_steps) or "- Keine"
blocked = "Ja" if result.blocked else "Nein"
approval = (
"Offen"
if result.approved is None
else "Freigegeben"
if result.approved
else "Abgelehnt"
)
self.result_text.setPlainText(
"\n\n".join(
[
f"Was gemacht wurde\n{result.summary}",
f"Was blockiert ist\n{blocked}",
f"Was du entschieden hast\n{decisions}",
f"Was als Nächstes sinnvoll ist\n{next_steps}",
f"Freigabe\n{approval}",
]
)
)
buttons_enabled = result.approved is None and not result.blocked
self.approve_button.setEnabled(buttons_enabled)
self.reject_button.setEnabled(buttons_enabled)
@staticmethod
def _empty_label(text: str) -> QLabel:
label = QLabel(text)
label.setObjectName("bodyText")
label.setWordWrap(True)
return label
def _set_status_state(self, state: str) -> None:
self.status_badge.setProperty("state", state)
self._refresh_widget_style(self.status_badge)
@staticmethod
def _refresh_widget_style(widget: QWidget) -> None:
widget.style().unpolish(widget)
widget.style().polish(widget)
@staticmethod
def _clear_layout(layout: QLayout) -> None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
child_layout = item.layout()
if widget is not None:
widget.setParent(None)
widget.deleteLater()
elif child_layout is not None:
MainWindow._clear_layout(child_layout)
def run_app(argv: list[str]) -> int:
QApplication.setOrganizationName("CLFGUI")
QApplication.setApplicationName("CLFGUI")
QApplication.setStyle("Fusion")
app = QApplication(argv)
window = MainWindow()
window.show()
return app.exec()