gui: session topology graph
Circular node layout showing all sessions with peer relationship edges. Nodes are color-coded by state (green=idle, blue=thinking, orange=calling), pulse when active. Click a node to select that session. Toggle between list and graph view in sidebar toolbar. Adds peerList() to OllieDBusClient.
This commit is contained in:
parent
8e3418c4fe
commit
6362e399bd
|
|
@ -247,7 +247,7 @@ else()
|
|||
)
|
||||
qt_add_resources(ollie-gui "gui"
|
||||
PREFIX "/"
|
||||
FILES gui/main.qml gui/emojis.js
|
||||
FILES gui/main.qml gui/SessionGraph.qml gui/emojis.js
|
||||
)
|
||||
|
||||
# --- Install ---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Session topology graph: nodes = sessions, edges = peer relationships
|
||||
Item {
|
||||
id: graphRoot
|
||||
|
||||
property var sessions: [] // [{id, state, model, agent}]
|
||||
property var peerListFn: null // function(sessionId) => [peerId, ...]
|
||||
|
||||
// Layout: arrange nodes in a circle
|
||||
property var nodePositions: ({})
|
||||
|
||||
onSessionsChanged: layoutNodes()
|
||||
onWidthChanged: layoutNodes()
|
||||
onHeightChanged: layoutNodes()
|
||||
|
||||
function layoutNodes() {
|
||||
let positions = {}
|
||||
let count = sessions.length
|
||||
if (count === 0) return
|
||||
|
||||
let cx = width / 2
|
||||
let cy = height / 2
|
||||
let radius = Math.min(width, height) * 0.35
|
||||
|
||||
// Single node goes in center
|
||||
if (count === 1) {
|
||||
positions[sessions[0].id] = {x: cx, y: cy}
|
||||
} else {
|
||||
for (let i = 0; i < count; i++) {
|
||||
let angle = (2 * Math.PI * i / count) - Math.PI / 2
|
||||
positions[sessions[i].id] = {
|
||||
x: cx + radius * Math.cos(angle),
|
||||
y: cy + radius * Math.sin(angle)
|
||||
}
|
||||
}
|
||||
}
|
||||
nodePositions = positions
|
||||
canvas.requestPaint()
|
||||
}
|
||||
|
||||
function stateColor(state) {
|
||||
if (state === "idle") return "#4caf50"
|
||||
if (state && state.startsWith("calling")) return "#ff9800"
|
||||
if (state === "thinking") return "#2196f3"
|
||||
return "#2196f3"
|
||||
}
|
||||
|
||||
// Edge canvas
|
||||
Canvas {
|
||||
id: canvas
|
||||
anchors.fill: parent
|
||||
|
||||
onPaint: {
|
||||
let ctx = getContext("2d")
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.4)
|
||||
ctx.lineWidth = 2
|
||||
|
||||
if (!peerListFn) return
|
||||
|
||||
for (let i = 0; i < sessions.length; i++) {
|
||||
let sid = sessions[i].id
|
||||
let peers = peerListFn(sid)
|
||||
let fromPos = nodePositions[sid]
|
||||
if (!fromPos) continue
|
||||
|
||||
for (let j = 0; j < peers.length; j++) {
|
||||
let toPos = nodePositions[peers[j]]
|
||||
if (!toPos) continue
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(fromPos.x, fromPos.y)
|
||||
ctx.lineTo(toPos.x, toPos.y)
|
||||
ctx.stroke()
|
||||
|
||||
// Arrow head
|
||||
let dx = toPos.x - fromPos.x
|
||||
let dy = toPos.y - fromPos.y
|
||||
let len = Math.sqrt(dx * dx + dy * dy)
|
||||
if (len < 1) continue
|
||||
let ux = dx / len
|
||||
let uy = dy / len
|
||||
// Arrow at 30px from target (node radius)
|
||||
let ax = toPos.x - ux * 30
|
||||
let ay = toPos.y - uy * 30
|
||||
let arrowSize = 8
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(ax, ay)
|
||||
ctx.lineTo(ax - arrowSize * ux + arrowSize * 0.5 * uy,
|
||||
ay - arrowSize * uy - arrowSize * 0.5 * ux)
|
||||
ctx.lineTo(ax - arrowSize * ux - arrowSize * 0.5 * uy,
|
||||
ay - arrowSize * uy + arrowSize * 0.5 * ux)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = Qt.rgba(0.5, 0.5, 0.5, 0.6)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session nodes
|
||||
Repeater {
|
||||
model: graphRoot.sessions
|
||||
|
||||
Rectangle {
|
||||
id: nodeRect
|
||||
width: 120
|
||||
height: 50
|
||||
radius: 8
|
||||
color: palette.base
|
||||
border.color: graphRoot.stateColor(modelData.state)
|
||||
border.width: modelData.id === dbus.activeSessionId ? 3 : 2
|
||||
|
||||
x: {
|
||||
let pos = graphRoot.nodePositions[modelData.id]
|
||||
return pos ? pos.x - width / 2 : 0
|
||||
}
|
||||
y: {
|
||||
let pos = graphRoot.nodePositions[modelData.id]
|
||||
return pos ? pos.y - height / 2 : 0
|
||||
}
|
||||
|
||||
// Pulse animation for active sessions
|
||||
SequentialAnimation on opacity {
|
||||
running: modelData.state !== "idle"
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.7; duration: 600; easing.type: Easing.InOutSine }
|
||||
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine }
|
||||
}
|
||||
opacity: modelData.state === "idle" ? 1.0 : opacity
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 2
|
||||
|
||||
Label {
|
||||
text: modelData.id
|
||||
font.bold: true
|
||||
font.pixelSize: 11
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
elide: Text.ElideRight
|
||||
width: nodeRect.width - 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Label {
|
||||
text: modelData.state || "idle"
|
||||
font.pixelSize: 9
|
||||
opacity: 0.7
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: graphRoot.stateColor(modelData.state)
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: dbus.activeSessionId = modelData.id
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: "No active sessions"
|
||||
opacity: 0.5
|
||||
visible: sessions.length === 0
|
||||
}
|
||||
}
|
||||
20
gui/main.qml
20
gui/main.qml
|
|
@ -229,6 +229,25 @@ ApplicationWindow {
|
|||
ToolTip.text: "Settings"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: checked ? "view-list-text" : "network-workgroup"
|
||||
checkable: true
|
||||
checked: false
|
||||
onToggled: graphView.visible = checked
|
||||
ToolTip.text: checked ? "Show session list" : "Show session graph"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
}
|
||||
|
||||
// Session graph (topology view)
|
||||
SessionGraph {
|
||||
id: graphView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
visible: false
|
||||
sessions: dbus.sessions
|
||||
peerListFn: function(sid) { return dbus.peerList(sid) }
|
||||
}
|
||||
|
||||
// Session list
|
||||
|
|
@ -236,6 +255,7 @@ ApplicationWindow {
|
|||
id: sessionList
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
visible: !graphView.visible
|
||||
clip: true
|
||||
model: dbus.sessions
|
||||
|
||||
|
|
|
|||
|
|
@ -341,6 +341,14 @@ void OllieDBusClient::onPortalFolderResponse(uint response, const QVariantMap &r
|
|||
}
|
||||
}
|
||||
|
||||
QStringList OllieDBusClient::peerList(const QString &sessionId)
|
||||
{
|
||||
QDBusReply<QStringList> reply = m_iface->call("PeerList", sessionId);
|
||||
if (reply.isValid())
|
||||
return reply.value();
|
||||
return {};
|
||||
}
|
||||
|
||||
bool OllieDBusClient::killSession(const QString &sessionId)
|
||||
{
|
||||
if (sessionId == m_activeSessionId) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ public:
|
|||
explicit OllieDBusClient(QObject *parent = nullptr);
|
||||
|
||||
QVariantList sessions() const { return m_sessions; }
|
||||
|
||||
Q_INVOKABLE QStringList peerList(const QString &sessionId);
|
||||
QString activeSessionId() const { return m_activeSessionId; }
|
||||
void setActiveSessionId(const QString &id);
|
||||
QString chatText() const { return m_chatText; }
|
||||
|
|
|
|||
Reference in New Issue