gui: add Peers button and peer management window
Canvas-based graph visualization of agents in current session: - Nodes represent agents, colored by state - Edges represent peer connections - Drag nodes to reposition - Click two nodes to toggle connection - Double-click to start connecting - Right-click for context menu - Pan with middle mouse, zoom with scroll wheel - Auto-layout arranges nodes in circle 9P API methods: listPeers, addPeer, removePeer, listAgentsInSession
This commit is contained in:
parent
05dd33c1cf
commit
56f6d3c655
|
|
@ -400,6 +400,16 @@ Item {
|
|||
ToolTip.text: "Background processes"
|
||||
}
|
||||
|
||||
// Peers button
|
||||
ToolButton {
|
||||
text: "🔗 Peers"
|
||||
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11
|
||||
implicitHeight: 28
|
||||
onClicked: peerManagerWindow.show()
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Manage peer connections"
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true } // Spacer
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,491 @@
|
|||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Window
|
||||
|
||||
Window {
|
||||
id: peerWindow
|
||||
title: "Peer Connections" + (currentSessionName ? " — " + currentSessionName : "")
|
||||
width: 700
|
||||
height: 500
|
||||
minimumWidth: 500
|
||||
minimumHeight: 400
|
||||
|
||||
property string currentSessionId: ollie.activeSessionId
|
||||
property string currentAgentId: ollie.activeAgentId
|
||||
property string currentSessionName: ""
|
||||
property var agents: [] // [{id, name, x, y, peers: [name1, name2...]}]
|
||||
property var selectedNode: null
|
||||
property var connectingFrom: null // Node we're drawing connection from
|
||||
|
||||
function refresh() {
|
||||
if (!currentSessionId) {
|
||||
agents = []
|
||||
return
|
||||
}
|
||||
|
||||
var agentList = ollie.listAgentsInSession(currentSessionId)
|
||||
var newAgents = []
|
||||
var radius = Math.min(canvas.width, canvas.height) * 0.35
|
||||
var centerX = canvas.width / 2
|
||||
var centerY = canvas.height / 2
|
||||
|
||||
for (var i = 0; i < agentList.length; i++) {
|
||||
var a = agentList[i]
|
||||
// Skip sub-agents (depth > 0)
|
||||
if (a.depth > 0) continue
|
||||
|
||||
var peers = ollie.listPeers(currentSessionId, a.id)
|
||||
|
||||
// Position in circle, or keep existing position
|
||||
var existing = findAgent(a.id)
|
||||
var angle = (2 * Math.PI * newAgents.length) / Math.max(agentList.length, 1) - Math.PI / 2
|
||||
|
||||
newAgents.push({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
state: a.state,
|
||||
x: existing ? existing.x : centerX + radius * Math.cos(angle),
|
||||
y: existing ? existing.y : centerY + radius * Math.sin(angle),
|
||||
peers: peers
|
||||
})
|
||||
}
|
||||
agents = newAgents
|
||||
canvas.requestPaint()
|
||||
}
|
||||
|
||||
function findAgent(id) {
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === id) return agents[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findAgentByName(name) {
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].name === name) return agents[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function nodeAt(x, y) {
|
||||
var nodeRadius = 30
|
||||
for (var i = agents.length - 1; i >= 0; i--) {
|
||||
var a = agents[i]
|
||||
var dx = x - a.x
|
||||
var dy = y - a.y
|
||||
if (dx * dx + dy * dy <= nodeRadius * nodeRadius) {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function updateSessionName() {
|
||||
if (currentSessionId) {
|
||||
var cfg = ollie.readSessionConfig(currentSessionId)
|
||||
currentSessionName = cfg.name || currentSessionId
|
||||
} else {
|
||||
currentSessionName = ""
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: 3000
|
||||
running: peerWindow.visible
|
||||
repeat: true
|
||||
onTriggered: refresh()
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
currentSessionId = ollie.activeSessionId
|
||||
currentAgentId = ollie.activeAgentId
|
||||
updateSessionName()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: ollie
|
||||
function onActiveSessionIdChanged() {
|
||||
if (peerWindow.visible) {
|
||||
currentSessionId = ollie.activeSessionId
|
||||
currentAgentId = ollie.activeAgentId
|
||||
updateSessionName()
|
||||
selectedNode = null
|
||||
connectingFrom = null
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
// Instructions
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: "Drag nodes to position. Click two nodes to connect/disconnect. Right-click for options."
|
||||
opacity: 0.7
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
// Canvas for graph
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
clip: true
|
||||
|
||||
Canvas {
|
||||
id: canvas
|
||||
anchors.fill: parent
|
||||
|
||||
property real panX: 0
|
||||
property real panY: 0
|
||||
property real zoom: 1.0
|
||||
property point lastMouse
|
||||
property bool panning: false
|
||||
property var draggingNode: null
|
||||
property point dragOffset
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
ctx.fillStyle = palette.base
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(panX, panY)
|
||||
ctx.scale(zoom, zoom)
|
||||
|
||||
var nodeRadius = 30
|
||||
|
||||
// Draw edges first
|
||||
ctx.strokeStyle = palette.highlight
|
||||
ctx.lineWidth = 2
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
var a = agents[i]
|
||||
for (var j = 0; j < a.peers.length; j++) {
|
||||
var peer = findAgentByName(a.peers[j])
|
||||
if (peer && peer.id > a.id) { // Draw each edge once
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(peer.x, peer.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw connection preview
|
||||
if (connectingFrom && mouseArea.containsMouse) {
|
||||
ctx.strokeStyle = "#888"
|
||||
ctx.setLineDash([5, 5])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(connectingFrom.x, connectingFrom.y)
|
||||
var mx = (mouseArea.mouseX - panX) / zoom
|
||||
var my = (mouseArea.mouseY - panY) / zoom
|
||||
ctx.lineTo(mx, my)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
var a = agents[i]
|
||||
var isSelected = selectedNode && selectedNode.id === a.id
|
||||
var isConnecting = connectingFrom && connectingFrom.id === a.id
|
||||
var isActive = a.id === currentAgentId
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath()
|
||||
ctx.arc(a.x, a.y, nodeRadius, 0, 2 * Math.PI)
|
||||
|
||||
// Fill based on state
|
||||
if (a.state === "idle") {
|
||||
ctx.fillStyle = "#4CAF50"
|
||||
} else if (a.state === "thinking" || a.state === "calling") {
|
||||
ctx.fillStyle = "#2196F3"
|
||||
} else if (a.state === "paused") {
|
||||
ctx.fillStyle = "#9E9E9E"
|
||||
} else {
|
||||
ctx.fillStyle = "#FF9800"
|
||||
}
|
||||
ctx.fill()
|
||||
|
||||
// Border
|
||||
if (isSelected || isConnecting) {
|
||||
ctx.strokeStyle = palette.highlightedText
|
||||
ctx.lineWidth = 3
|
||||
} else if (isActive) {
|
||||
ctx.strokeStyle = palette.highlight
|
||||
ctx.lineWidth = 2
|
||||
} else {
|
||||
ctx.strokeStyle = palette.text
|
||||
ctx.lineWidth = 1
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = palette.text
|
||||
ctx.font = "12px sans-serif"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
var label = a.name.length > 10 ? a.name.substring(0, 9) + "…" : a.name
|
||||
ctx.fillText(label, a.x, a.y + nodeRadius + 15)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
|
||||
onPressed: function(mouse) {
|
||||
var x = (mouse.x - canvas.panX) / canvas.zoom
|
||||
var y = (mouse.y - canvas.panY) / canvas.zoom
|
||||
var node = nodeAt(x, y)
|
||||
|
||||
if (mouse.button === Qt.MiddleButton) {
|
||||
canvas.panning = true
|
||||
canvas.lastMouse = Qt.point(mouse.x, mouse.y)
|
||||
} else if (mouse.button === Qt.RightButton && node) {
|
||||
selectedNode = node
|
||||
nodeContextMenu.popup()
|
||||
} else if (mouse.button === Qt.LeftButton) {
|
||||
if (node) {
|
||||
if (connectingFrom) {
|
||||
// Complete connection
|
||||
if (connectingFrom.id !== node.id) {
|
||||
toggleConnection(connectingFrom, node)
|
||||
}
|
||||
connectingFrom = null
|
||||
} else {
|
||||
// Start drag or connection
|
||||
canvas.draggingNode = node
|
||||
canvas.dragOffset = Qt.point(x - node.x, y - node.y)
|
||||
selectedNode = node
|
||||
}
|
||||
} else {
|
||||
// Click on empty space
|
||||
if (connectingFrom) {
|
||||
connectingFrom = null
|
||||
} else {
|
||||
selectedNode = null
|
||||
}
|
||||
}
|
||||
canvas.requestPaint()
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: function(mouse) {
|
||||
if (canvas.panning) {
|
||||
canvas.panning = false
|
||||
}
|
||||
if (canvas.draggingNode) {
|
||||
canvas.draggingNode = null
|
||||
}
|
||||
}
|
||||
|
||||
onPositionChanged: function(mouse) {
|
||||
if (canvas.panning) {
|
||||
canvas.panX += mouse.x - canvas.lastMouse.x
|
||||
canvas.panY += mouse.y - canvas.lastMouse.y
|
||||
canvas.lastMouse = Qt.point(mouse.x, mouse.y)
|
||||
canvas.requestPaint()
|
||||
} else if (canvas.draggingNode) {
|
||||
var x = (mouse.x - canvas.panX) / canvas.zoom
|
||||
var y = (mouse.y - canvas.panY) / canvas.zoom
|
||||
canvas.draggingNode.x = x - canvas.dragOffset.x
|
||||
canvas.draggingNode.y = y - canvas.dragOffset.y
|
||||
canvas.requestPaint()
|
||||
} else if (connectingFrom) {
|
||||
canvas.requestPaint()
|
||||
}
|
||||
}
|
||||
|
||||
onWheel: function(wheel) {
|
||||
var factor = wheel.angleDelta.y > 0 ? 1.1 : 0.9
|
||||
var newZoom = Math.max(0.3, Math.min(3.0, canvas.zoom * factor))
|
||||
|
||||
// Zoom toward mouse position
|
||||
var mx = wheel.x
|
||||
var my = wheel.y
|
||||
canvas.panX = mx - (mx - canvas.panX) * (newZoom / canvas.zoom)
|
||||
canvas.panY = my - (my - canvas.panY) * (newZoom / canvas.zoom)
|
||||
canvas.zoom = newZoom
|
||||
canvas.requestPaint()
|
||||
}
|
||||
|
||||
onDoubleClicked: function(mouse) {
|
||||
var x = (mouse.x - canvas.panX) / canvas.zoom
|
||||
var y = (mouse.y - canvas.panY) / canvas.zoom
|
||||
var node = nodeAt(x, y)
|
||||
if (node) {
|
||||
// Start connecting from this node
|
||||
connectingFrom = node
|
||||
canvas.requestPaint()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
id: nodeContextMenu
|
||||
|
||||
MenuItem {
|
||||
text: "Connect to..."
|
||||
enabled: selectedNode !== null
|
||||
onTriggered: {
|
||||
connectingFrom = selectedNode
|
||||
canvas.requestPaint()
|
||||
}
|
||||
}
|
||||
|
||||
MenuItem {
|
||||
text: "Disconnect all"
|
||||
enabled: selectedNode !== null && selectedNode.peers.length > 0
|
||||
onTriggered: {
|
||||
if (selectedNode) {
|
||||
for (var i = 0; i < selectedNode.peers.length; i++) {
|
||||
ollie.removePeer(currentSessionId, selectedNode.id, selectedNode.peers[i])
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MenuSeparator {}
|
||||
|
||||
MenuItem {
|
||||
text: "Switch to agent"
|
||||
enabled: selectedNode !== null
|
||||
onTriggered: {
|
||||
if (selectedNode) {
|
||||
ollie.switchAgent(currentSessionId, selectedNode.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom controls
|
||||
RowLayout {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
spacing: 4
|
||||
|
||||
Button {
|
||||
text: "−"
|
||||
implicitWidth: 30
|
||||
implicitHeight: 30
|
||||
onClicked: {
|
||||
canvas.zoom = Math.max(0.3, canvas.zoom * 0.8)
|
||||
canvas.requestPaint()
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: Math.round(canvas.zoom * 100) + "%"
|
||||
Layout.preferredWidth: 45
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "+"
|
||||
implicitWidth: 30
|
||||
implicitHeight: 30
|
||||
onClicked: {
|
||||
canvas.zoom = Math.min(3.0, canvas.zoom * 1.25)
|
||||
canvas.requestPaint()
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Reset"
|
||||
implicitWidth: 50
|
||||
implicitHeight: 30
|
||||
onClicked: {
|
||||
canvas.zoom = 1.0
|
||||
canvas.panX = 0
|
||||
canvas.panY = 0
|
||||
refresh() // Re-layout nodes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Button {
|
||||
text: "Refresh"
|
||||
icon.name: "view-refresh"
|
||||
onClicked: refresh()
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Auto Layout"
|
||||
icon.name: "view-grid"
|
||||
onClicked: {
|
||||
// Re-position all nodes in a circle
|
||||
var radius = Math.min(canvas.width, canvas.height) * 0.35 / canvas.zoom
|
||||
var centerX = (canvas.width / 2 - canvas.panX) / canvas.zoom
|
||||
var centerY = (canvas.height / 2 - canvas.panY) / canvas.zoom
|
||||
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
var angle = (2 * Math.PI * i) / agents.length - Math.PI / 2
|
||||
agents[i].x = centerX + radius * Math.cos(angle)
|
||||
agents[i].y = centerY + radius * Math.sin(angle)
|
||||
}
|
||||
canvas.requestPaint()
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Label {
|
||||
visible: connectingFrom !== null
|
||||
text: "Click another node to connect, or empty space to cancel"
|
||||
color: palette.highlight
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Button {
|
||||
text: "Close"
|
||||
icon.name: "window-close"
|
||||
onClicked: peerWindow.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConnection(a, b) {
|
||||
// Check if already connected
|
||||
var connected = false
|
||||
for (var i = 0; i < a.peers.length; i++) {
|
||||
if (a.peers[i] === b.name) {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (connected) {
|
||||
ollie.removePeer(currentSessionId, a.id, b.name)
|
||||
} else {
|
||||
ollie.addPeer(currentSessionId, a.id, b.name)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
|
@ -197,6 +197,12 @@ ApplicationWindow {
|
|||
visible: false
|
||||
}
|
||||
|
||||
// --- Peer Manager Window ---
|
||||
PeerManagerWindow {
|
||||
id: peerManagerWindow
|
||||
visible: false
|
||||
}
|
||||
|
||||
SplitView {
|
||||
anchors.fill: parent
|
||||
|
||||
|
|
|
|||
|
|
@ -971,6 +971,75 @@ void Ollie9pClient::openDirectory(const QString &path)
|
|||
QDesktopServices::openUrl(QUrl::fromLocalFile(path));
|
||||
}
|
||||
|
||||
// --- Peer management ---
|
||||
|
||||
QStringList Ollie9pClient::listPeers(const QString &sessionId, const QString &agentId)
|
||||
{
|
||||
QStringList result;
|
||||
if (sessionId.isEmpty() || agentId.isEmpty()) return result;
|
||||
if (!m_9p || !m_9p->isConnected()) return result;
|
||||
|
||||
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
|
||||
QByteArray out = m_9p->rdwr(path, "peers");
|
||||
QString raw = QString::fromUtf8(out).trimmed();
|
||||
|
||||
if (raw.isEmpty() || raw == "(no peers)") return result;
|
||||
|
||||
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
|
||||
result.append(line.trimmed());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Ollie9pClient::addPeer(const QString &sessionId, const QString &agentId, const QString &peerName)
|
||||
{
|
||||
if (sessionId.isEmpty() || agentId.isEmpty() || peerName.isEmpty()) return false;
|
||||
if (!m_9p || !m_9p->isConnected()) return false;
|
||||
|
||||
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
|
||||
QString data = "peeradd " + peerName;
|
||||
QByteArray out = m_9p->rdwr(path, data.toUtf8());
|
||||
return out.trimmed() == "ok";
|
||||
}
|
||||
|
||||
bool Ollie9pClient::removePeer(const QString &sessionId, const QString &agentId, const QString &peerName)
|
||||
{
|
||||
if (sessionId.isEmpty() || agentId.isEmpty() || peerName.isEmpty()) return false;
|
||||
if (!m_9p || !m_9p->isConnected()) return false;
|
||||
|
||||
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
|
||||
QString data = "peerdel " + peerName;
|
||||
QByteArray out = m_9p->rdwr(path, data.toUtf8());
|
||||
return out.trimmed() == "ok";
|
||||
}
|
||||
|
||||
QVariantList Ollie9pClient::listAgentsInSession(const QString &sessionId)
|
||||
{
|
||||
QVariantList result;
|
||||
if (sessionId.isEmpty()) return result;
|
||||
if (!m_9p || !m_9p->isConnected()) return result;
|
||||
|
||||
QString path = "session/" + sessionId + "/agent/idx";
|
||||
QByteArray out = m_9p->read(path);
|
||||
QString raw = QString::fromUtf8(out);
|
||||
|
||||
if (raw.isEmpty()) return result;
|
||||
|
||||
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
|
||||
QStringList parts = line.split('\t');
|
||||
if (parts.size() < 3) continue;
|
||||
|
||||
QVariantMap agent;
|
||||
agent["id"] = parts.value(1);
|
||||
agent["name"] = parts.value(2);
|
||||
agent["parentAgentId"] = parts.value(3);
|
||||
agent["depth"] = parts.value(4).toInt();
|
||||
agent["state"] = parts.value(5);
|
||||
result.append(agent);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Root data loading (lazy) ---
|
||||
|
||||
void Ollie9pClient::ensureRootDataLoaded()
|
||||
|
|
|
|||
|
|
@ -103,6 +103,12 @@ public:
|
|||
Q_INVOKABLE void openDirectory(const QString &path);
|
||||
Q_INVOKABLE QString lastError() const { return m_9p ? m_9p->lastError() : QString(); }
|
||||
|
||||
// Peer management
|
||||
Q_INVOKABLE QStringList listPeers(const QString &sessionId, const QString &agentId);
|
||||
Q_INVOKABLE bool addPeer(const QString &sessionId, const QString &agentId, const QString &peerName);
|
||||
Q_INVOKABLE bool removePeer(const QString &sessionId, const QString &agentId, const QString &peerName);
|
||||
Q_INVOKABLE QVariantList listAgentsInSession(const QString &sessionId);
|
||||
|
||||
signals:
|
||||
void sessionsChanged();
|
||||
void activeSessionIdChanged();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<file>NewAgentDialog.qml</file>
|
||||
<file>GoalDialog.qml</file>
|
||||
<file>ProcManagerWindow.qml</file>
|
||||
<file>PeerManagerWindow.qml</file>
|
||||
<file>ChatBlockDelegate.qml</file>
|
||||
<file>SessionTree.qml</file>
|
||||
<file>ChatPane.qml</file>
|
||||
|
|
|
|||
Loading…
Reference in New Issue