332 lines
12 KiB
Swift
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
|
|
}
|
|
}
|