Add review workflows, tree-shaped workflows and a workflow editor
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 review = false
|
||||
@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 || name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
if let root {
|
||||
if original == nil {
|
||||
TextField("Name", text: $name)
|
||||
.modifier(FieldStyle())
|
||||
.padding(.top, 12)
|
||||
} else {
|
||||
Text(name)
|
||||
.font(Fonts.font(13, weight: 700))
|
||||
.textSelection(.enabled)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
TextField("Description", text: $description, axis: .vertical)
|
||||
.modifier(FieldStyle())
|
||||
.padding(.top, 8)
|
||||
Picker("", selection: $review) {
|
||||
Text("Runs on a brief, each stage its own task").tag(false)
|
||||
Text("Reviews snapshots, each stage a reviewer").tag(true)
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
.labelsHidden()
|
||||
.padding(.top, 8)
|
||||
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, kind: review ? .review : nil, steps: $0.children.map(\.step)) }
|
||||
}
|
||||
|
||||
private var changed: Bool {
|
||||
original == nil || current != loaded
|
||||
}
|
||||
|
||||
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 ?? ""
|
||||
review = def.kind == .review
|
||||
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 }
|
||||
let target = name.trimmingCharacters(in: .whitespaces)
|
||||
do {
|
||||
_ = try await store.api.client.putWorkflow(path: .init(name: target), body: .json(def)).ok.body.json
|
||||
failure = ""
|
||||
loaded = def
|
||||
if original == nil {
|
||||
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()
|
||||
Button("Move up") { move(-1) }
|
||||
.disabled(node.index == 0)
|
||||
Button("Move down") { move(1) }
|
||||
.disabled(node.index == node.parent!.children.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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user