492 lines
18 KiB
QML
492 lines
18 KiB
QML
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()
|
||
}
|
||
}
|