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

484 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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), headers: .init(accept: [.init(contentType: .json)])).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))
}
}