Files
byre/mac/Sources/Byre/WorkflowEditor.swift
T

484 lines
16 KiB
Swift
Raw Normal View History

import ByreAPI
import SwiftUI
typealias WorkflowDefinition = Components.Schemas.WorkflowDefinition
typealias WorkflowStep = Components.Schemas.WorkflowStep
typealias TeamMember = Components.Schemas.TeamMember
@MainActor
@Observable
final class StepNode: Identifiable {
enum Kind {
case stage, order, parallel
}
let id = UUID()
var kind: Kind
var name = ""
var model = ""
var prompt = ""
var agents: [TeamMember]?
var children: [StepNode] = []
weak var parent: StepNode?
var open = false
init(kind: Kind, children: [StepNode] = []) {
self.kind = kind
self.children = children
for c in children {
c.parent = self
}
}
convenience init(_ step: WorkflowStep) {
if let order = step.order {
self.init(kind: .order, children: order.map { StepNode($0) })
} else if let parallel = step.parallel {
self.init(kind: .parallel, children: parallel.map { StepNode($0) })
} else {
self.init(kind: .stage)
name = step.stage ?? ""
model = step.model ?? ""
prompt = step.prompt ?? ""
agents = step.agents
}
}
var step: WorkflowStep {
switch kind {
case .stage:
WorkflowStep(stage: name, model: agents == nil ? model : nil, prompt: prompt, agents: agents)
case .order:
WorkflowStep(order: children.map(\.step))
case .parallel:
WorkflowStep(parallel: children.map(\.step))
}
}
var index: Int {
parent!.children.firstIndex { $0 === self }!
}
func contains(_ other: StepNode) -> Bool {
other === self || children.contains { $0.contains(other) }
}
func stageNames() -> [String] {
kind == .stage ? [name] : children.flatMap { $0.stageNames() }
}
func insert(_ node: StepNode, at i: Int) {
node.parent = self
children.insert(node, at: i)
}
func detach() {
parent?.children.remove(at: index)
parent = nil
}
}
@MainActor
private func newStage(_ root: StepNode, _ model: String) -> StepNode {
let names = Set(root.stageNames())
var n = names.count + 1
while names.contains("Stage \(n)") {
n += 1
}
let s = StepNode(kind: .stage)
s.name = "Stage \(n)"
s.model = model
s.open = true
return s
}
@MainActor
private func stepGraph(_ root: StepNode) -> Flowchart {
var chart = Flowchart(direction: .td)
func walk(_ node: StepNode, _ before: [String]) -> [String] {
switch node.kind {
case .order:
return node.children.reduce(before) { walk($1, $0) }
case .parallel:
return node.children.flatMap { walk($0, before) }
case .stage:
let id = "n\(chart.order.count)"
chart.node(id, label: node.name.isEmpty ? " " : node.name, shape: .box)
for b in before {
chart.edges.append(FlowEdge(from: b, to: id, label: nil, style: .solid, arrow: true))
}
return [id]
}
}
_ = walk(root, [])
return chart
}
private struct StepLine: Identifiable {
let node: StepNode
let guide: String
var id: UUID { node.id }
}
@MainActor
private func stepLines(_ node: StepNode, _ lead: String) -> [StepLine] {
var out: [StepLine] = []
for (i, c) in node.children.enumerated() {
let last = i == node.children.count - 1
out.append(StepLine(node: c, guide: lead + (last ? "└─ " : "├─ ")))
out += stepLines(c, lead + (last ? " " : "│ "))
}
return out
}
struct WorkflowEditor: View {
let original: String?
let close: () -> Void
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var root: StepNode?
@ViewState private var loaded: WorkflowDefinition?
@ViewState private var name = ""
@ViewState private var description = ""
@ViewState private var failure = ""
@ViewState private var saving = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
Button("‹ workflows") {
Task {
await leave()
}
}
.buttonStyle(SmallLinkButtonStyle())
Spacer()
Button("save") {
Task {
await save()
}
}
.buttonStyle(WebButtonStyle(primary: true))
.disabled(saving || !changed || target.isEmpty)
}
.padding(.top, 16)
if let root {
TextField("Name", text: $name)
.modifier(FieldStyle())
.padding(.top, 12)
TextField("Description", text: $description, axis: .vertical)
.modifier(FieldStyle())
.padding(.top, 8)
if !root.stageNames().isEmpty {
FlowchartCanvas(layout: layoutFlowchart(stepGraph(root)))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 12)
}
if !failure.isEmpty {
Text(failure)
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 8)
}
VStack(alignment: .leading, spacing: 0) {
ForEach(stepLines(root, "")) { row in
StepRow(node: row.node, guide: row.guide, root: root)
}
}
.padding(.top, 12)
Button("add stage") {
root.insert(newStage(root, store.defaultModel), at: root.children.count)
}
.buttonStyle(SmallLinkButtonStyle())
.padding(.top, 8)
} else if !failure.isEmpty {
Text(failure).foregroundStyle(Palette.muted).padding(.top, 12)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.bg)
.task {
await load()
}
}
private var current: WorkflowDefinition? {
root.map { WorkflowDefinition(description: description.isEmpty ? nil : description, steps: $0.children.map(\.step)) }
}
private var target: String {
name.trimmingCharacters(in: .whitespaces)
}
private var changed: Bool {
original == nil || current != loaded || target != original
}
private func load() async {
guard let original else {
let r = StepNode(kind: .order)
r.insert(newStage(r, store.defaultModel), at: 0)
root = r
return
}
do {
let def = try await store.api.client.getWorkflow(path: .init(name: original)).ok.body.json
name = original
description = def.description ?? ""
root = StepNode(kind: .order, children: def.steps.map { StepNode($0) })
loaded = current
} catch {
failure = errorText(error)
}
}
private func save() async {
guard let def = current else {
return
}
saving = true
defer { saving = false }
do {
_ = try await store.api.client.putWorkflow(path: .init(name: target), query: .init(from: original), body: .json(def)).ok.body.json
close()
} catch {
failure = errorText(error)
}
}
private func leave() async {
if changed, current != nil, !(await overlays.showConfirm(["This workflow has unsaved changes."], action: "Discard changes", danger: true)) {
return
}
close()
}
}
private struct StepRow: View {
@Bindable var node: StepNode
let guide: String
let root: StepNode
@Environment(Store.self) private var store
@ViewState private var target = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(guide).foregroundStyle(Palette.muted)
switch node.kind {
case .stage:
Button {
node.open.toggle()
} label: {
Image(systemName: node.open ? "chevron.down" : "chevron.right")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(Palette.muted)
.frame(width: 12)
}
.buttonStyle(.plain)
TextField("Stage name", text: $node.name)
.textFieldStyle(.plain)
.font(Fonts.font(13, weight: 700))
if let agents = node.agents {
Text("team of \(agents.count)").foregroundStyle(Palette.muted).fixedSize()
} else {
StepModelMenu(model: $node.model)
}
case .order:
Text("in order").foregroundStyle(Palette.muted)
Spacer()
case .parallel:
Text("in parallel").foregroundStyle(Palette.muted)
Spacer()
}
actions
}
.padding(.vertical, 3)
.background(target ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 4))
.draggable(node.id.uuidString)
.dropDestination(for: String.self) { items, _ in
drop(items)
} isTargeted: {
target = $0
}
if node.kind == .stage && node.open {
VStack(alignment: .leading, spacing: 6) {
if node.agents != nil {
Text("Every agent's prompt").foregroundStyle(Palette.muted)
}
PromptEditor(text: $node.prompt)
if let agents = node.agents {
ForEach(agents.indices, id: \.self) { i in
HStack(spacing: 6) {
TextField("Agent name", text: Binding(get: { node.agents?[i].name ?? "" }, set: { node.agents?[i].name = $0 }))
.textFieldStyle(.plain)
.font(Fonts.font(13, weight: 700))
StepModelMenu(model: Binding(get: { node.agents?[i].model ?? "" }, set: { node.agents?[i].model = $0 }))
}
.padding(.top, 4)
PromptEditor(text: Binding(get: { node.agents?[i].prompt ?? "" }, set: { node.agents?[i].prompt = $0 }))
}
}
}
.padding(.leading, CGFloat(guide.count) * 8 + 18)
.padding(.bottom, 8)
}
}
}
private var actions: some View {
Menu {
switch node.kind {
case .stage:
Button("Add stage after") { addAfter() }
Button("Run in parallel with a new stage") { wrap(.parallel) }
Button("Group with a new stage in order") { wrap(.order) }
case .order, .parallel:
Button("Add stage inside") { node.insert(stage(), at: node.children.count) }
Button("Add stage after") { addAfter() }
Button(node.kind == .order ? "Change to in parallel" : "Change to in order") {
node.kind = node.kind == .order ? .parallel : .order
}
Button("Ungroup") { ungroup() }
}
Divider()
let siblings = node.parent?.children ?? []
let i = siblings.firstIndex { $0 === node }
Button("Move up") { move(-1) }
.disabled(i == nil || i == 0)
Button("Move down") { move(1) }
.disabled(i == nil || i == siblings.count - 1)
Divider()
Button("Remove") { node.detach() }
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Palette.muted)
.frame(width: 20, height: 16)
.contentShape(Rectangle())
}
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.fixedSize()
}
private func stage() -> StepNode {
newStage(root, store.defaultModel)
}
private func addAfter() {
node.parent!.insert(stage(), at: node.index + 1)
}
private func wrap(_ kind: StepNode.Kind) {
let parent = node.parent!
let i = node.index
node.detach()
let group = StepNode(kind: kind)
group.insert(node, at: 0)
group.insert(stage(), at: 1)
parent.insert(group, at: i)
}
private func ungroup() {
let parent = node.parent!
let i = node.index
let children = node.children
node.detach()
for (n, c) in children.enumerated() {
parent.insert(c, at: i + n)
}
}
private func move(_ by: Int) {
let parent = node.parent!
let i = node.index
node.detach()
parent.insert(node, at: i + by)
}
private func drop(_ items: [String]) -> Bool {
guard let id = items.first.flatMap(UUID.init(uuidString:)), let dragged = find(root, id), !dragged.contains(node) else {
return false
}
dragged.detach()
if node.kind == .stage {
node.parent!.insert(dragged, at: node.index)
} else {
node.insert(dragged, at: node.children.count)
}
return true
}
private func find(_ n: StepNode, _ id: UUID) -> StepNode? {
if n.id == id {
return n
}
for c in n.children {
if let f = find(c, id) {
return f
}
}
return nil
}
}
private struct StepModelMenu: View {
@Binding var model: String
var body: some View {
Menu {
ForEach(models, id: \.self) { m in
Button(m) { model = m }
}
} label: {
HStack(spacing: 6) {
Text(model.isEmpty ? "model" : model)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .semibold))
}
.font(Fonts.font(12))
.foregroundStyle(model.isEmpty ? Palette.muted : Palette.text)
.contentShape(Rectangle())
}
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.fixedSize()
}
}
private struct PromptEditor: View {
@Binding var text: String
@FocusState private var focused: Bool
var body: some View {
TextEditor(text: $text)
.font(Fonts.font(12))
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused($focused)
.padding(.horizontal, 5)
.padding(.vertical, 4)
.frame(height: 160)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Prompt…")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.allowsHitTesting(false)
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border))
}
}