CxLLM-IOS/CxLLMStudio/Views/Agent/AgentView.swift
2026-05-16 10:52:04 -05:00

332 lines
12 KiB
Swift

// CxLLM Studio iOS Application
// Views/Agent/AgentView.swift Agent workspace for iOS
import SwiftUI
import CxCode
struct AgentView: View {
@Environment(AgentService.self) private var agent
@State private var taskInput = ""
@State private var maxSteps = 10
@State private var output = ""
@State private var selectedTool: AgentToolInfo?
@State private var toolArgs = "{}"
@State private var toolResult = ""
@State private var showToolBrowser = false
private let agentGreen = Color(red: 0.46, green: 0.72, blue: 0.0)
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
statusSection
autopilotSection
quickTasksSection
if !output.isEmpty { outputSection }
toolExecutionSection
if !agent.history.isEmpty { historySection }
}
.padding()
}
.navigationTitle("Agent")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showToolBrowser = true } label: {
Label("Tools", systemImage: "wrench.and.screwdriver")
}
}
ToolbarItem(placement: .topBarTrailing) {
statusPill
}
}
.sheet(isPresented: $showToolBrowser) {
toolBrowserSheet
}
.task { await agent.initialize() }
}
// MARK: - Status
private var statusSection: some View {
HStack(spacing: 12) {
statCard("Tools", "\(agent.tools.count)", "wrench", agentGreen)
statCard("Runs", "\(agent.totalExecutions)", "play.circle", .blue)
statCard("Calls", "\(agent.totalToolCalls)", "arrow.right.circle", .purple)
}
}
private func statCard(_ label: String, _ value: String, _ icon: String, _ color: Color) -> some View {
VStack(spacing: 4) {
Image(systemName: icon).font(.system(size: 18)).foregroundStyle(color)
Text(value).font(.system(size: 20, weight: .bold, design: .rounded))
Text(label).font(.caption2).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(color.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Autopilot
private var autopilotSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Autopilot", systemImage: "bolt.fill")
.font(.headline).foregroundStyle(agentGreen)
TextField("Describe a task...", text: $taskInput, axis: .vertical)
.lineLimit(2...5)
.textFieldStyle(.roundedBorder)
HStack {
HStack(spacing: 4) {
Text("Max steps:").font(.caption).foregroundStyle(.secondary)
TextField("", value: $maxSteps, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 50)
.font(.caption)
}
Spacer()
Button {
runAutopilot()
} label: {
HStack(spacing: 4) {
if agent.isRunning {
ProgressView().controlSize(.small)
} else {
Image(systemName: "play.fill")
}
Text(agent.isRunning ? "Running..." : "Execute")
}
.font(.subheadline.weight(.semibold))
}
.buttonStyle(.borderedProminent).tint(agentGreen)
.disabled(taskInput.isEmpty || agent.isRunning || !agent.isHealthy)
}
}
}
// MARK: - Quick Tasks
private var quickTasksSection: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
quickTaskChip("Analyze project", "magnifyingglass")
quickTaskChip("Fix bugs", "ant")
quickTaskChip("Write tests", "checkmark.circle")
quickTaskChip("Refactor", "arrow.triangle.2.circlepath")
quickTaskChip("Document", "doc.text")
}
}
}
private func quickTaskChip(_ text: String, _ icon: String) -> some View {
Button { taskInput = text } label: {
Label(text, systemImage: icon).font(.caption)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.primary.opacity(0.05))
.clipShape(Capsule())
}.buttonStyle(.plain)
}
// MARK: - Output
private var outputSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Label("Output", systemImage: "text.alignleft")
.font(.headline)
Spacer()
Button { UIPasteboard.general.string = output } label: {
Image(systemName: "doc.on.doc").font(.caption)
}
}
Text(output)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.primary.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Tool Execution
private var toolExecutionSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Direct Tool Call", systemImage: "terminal")
.font(.headline)
if let tool = selectedTool {
HStack {
Label(tool.name, systemImage: "wrench").font(.subheadline.weight(.medium))
Spacer()
Button("Change") { showToolBrowser = true }
.font(.caption).buttonStyle(.bordered).controlSize(.small)
}
if let desc = tool.description {
Text(desc).font(.caption).foregroundStyle(.secondary)
}
} else {
Button { showToolBrowser = true } label: {
Label("Select a tool", systemImage: "plus.circle")
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.buttonStyle(.bordered)
}
Text("Arguments (JSON)").font(.caption).foregroundStyle(.secondary)
TextEditor(text: $toolArgs)
.font(.system(.caption, design: .monospaced))
.scrollContentBackground(.hidden)
.frame(minHeight: 60, maxHeight: 120)
.padding(8)
.background(Color.primary.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 8))
Button { execTool() } label: {
HStack(spacing: 4) {
if agent.isExecutingTool {
ProgressView().controlSize(.small)
} else {
Image(systemName: "play.fill")
}
Text("Execute")
}
.font(.subheadline.weight(.medium))
}
.buttonStyle(.borderedProminent).controlSize(.small)
.disabled(selectedTool == nil || agent.isExecutingTool)
if !toolResult.isEmpty {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Result").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Spacer()
Button { UIPasteboard.general.string = toolResult } label: {
Image(systemName: "doc.on.doc").font(.caption2)
}
}
Text(toolResult)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.primary.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - History
private var historySection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("History", systemImage: "clock").font(.headline)
Spacer()
Button("Clear") { agent.clearHistory() }
.font(.caption).buttonStyle(.bordered).controlSize(.mini)
}
ForEach(agent.history) { h in
HStack(spacing: 10) {
Image(systemName: h.success ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(h.success ? .green : .red)
.font(.body)
VStack(alignment: .leading, spacing: 2) {
Text(h.task).font(.subheadline.weight(.medium)).lineLimit(1)
Text("\(h.steps.count) steps · \(String(format: "%.1fs", h.duration))")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Text(h.time, format: .dateTime.hour().minute())
.font(.caption2.monospacedDigit()).foregroundStyle(.tertiary)
}
.padding(10)
.background(Color.primary.opacity(0.02))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
// MARK: - Tool Browser Sheet
private var toolBrowserSheet: some View {
NavigationStack {
List(agent.tools, id: \.name) { tool in
Button {
selectedTool = tool
showToolBrowser = false
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(tool.name).font(.subheadline.weight(.medium))
if let desc = tool.description {
Text(desc).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
}
}
}
.navigationTitle("Agent Tools")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { showToolBrowser = false }
}
}
}
}
// MARK: - Components
private var statusPill: some View {
HStack(spacing: 4) {
Circle().fill(agent.isHealthy ? Color.green : Color.red.opacity(0.5)).frame(width: 6, height: 6)
Text(agent.isHealthy ? "Ready" : "Offline").font(.caption2.weight(.semibold))
}
.padding(.horizontal, 8).padding(.vertical, 4)
.background(agent.isHealthy ? Color.green.opacity(0.08) : Color.red.opacity(0.08))
.clipShape(Capsule())
}
// MARK: - Actions
private func runAutopilot() {
guard !taskInput.isEmpty else { return }
output = ""
Task {
do {
let execution = try await agent.runAutopilot(task: taskInput, maxSteps: maxSteps)
output = execution.output
} catch {
output = "Error: \(error.localizedDescription)"
}
}
}
private func execTool() {
guard let tool = selectedTool else { return }
toolResult = ""
Task {
do {
let args = parseJSON(toolArgs)
toolResult = try await agent.callTool(name: tool.name, arguments: args)
} catch {
toolResult = "Error: \(error.localizedDescription)"
}
}
}
private func parseJSON(_ s: String) -> [String: Any] {
guard let d = s.data(using: .utf8),
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any]
else { return [:] }
return o
}
}