456 lines
14 KiB
Swift
456 lines
14 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true)
|
|
private let defaultProfile = "fidelity"
|
|
|
|
@main
|
|
struct AIWorkspaceApp: App {
|
|
@StateObject private var model = ServiceStatusModel(profile: defaultProfile)
|
|
|
|
var body: some Scene {
|
|
MenuBarExtra {
|
|
ServiceMenuView(model: model)
|
|
.task {
|
|
await model.refresh()
|
|
}
|
|
} label: {
|
|
Label("AI Workspace", systemImage: model.menuBarSymbol)
|
|
}
|
|
.menuBarExtraStyle(.window)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ServiceStatusModel: ObservableObject {
|
|
@Published private(set) var report: StatusReport?
|
|
@Published private(set) var lastError: String?
|
|
@Published private(set) var lanIP: String?
|
|
@Published private(set) var isRefreshing = false
|
|
|
|
let profile: String
|
|
|
|
init(profile: String) {
|
|
self.profile = profile
|
|
}
|
|
|
|
var menuBarSymbol: String {
|
|
guard let report else { return "circle.dashed" }
|
|
if report.services.contains(where: { $0.status == "unhealthy" || ($0.enabled && $0.status == "stopped") }) {
|
|
return "exclamationmark.triangle"
|
|
}
|
|
if report.services.contains(where: { $0.status == "running" }) {
|
|
return "checkmark.circle"
|
|
}
|
|
return "circle"
|
|
}
|
|
|
|
func refresh() async {
|
|
isRefreshing = true
|
|
defer { isRefreshing = false }
|
|
do {
|
|
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
|
|
report = try JSONDecoder().decode(StatusReport.self, from: data)
|
|
lanIP = await NetworkInfo.primaryLANIP()
|
|
lastError = nil
|
|
} catch {
|
|
lastError = String(describing: error)
|
|
}
|
|
}
|
|
|
|
func startProfile() {
|
|
runAction(["start", "--profile", profile])
|
|
}
|
|
|
|
func stopProfile() {
|
|
runAction(["stop", "--profile", profile])
|
|
}
|
|
|
|
func restartMCP() {
|
|
runAction(["restart", "aiw-context-mcp", "--profile", profile])
|
|
}
|
|
|
|
func runDoctor() {
|
|
runAction(["doctor", "--profile", profile])
|
|
}
|
|
|
|
func copyDoctorJSON() {
|
|
Task {
|
|
do {
|
|
let data = try await ServiceManager.run(["doctor", "--profile", profile, "--json"])
|
|
copyToPasteboard(String(data: data, encoding: .utf8) ?? "")
|
|
} catch {
|
|
lastError = String(describing: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func copyPhotoInboxURL() {
|
|
let host = lanIP ?? "127.0.0.1"
|
|
copyToPasteboard("http://\(host):8787/upload")
|
|
}
|
|
|
|
func copyRecentLogs() {
|
|
Task {
|
|
do {
|
|
var chunks: [String] = []
|
|
for service in report?.services ?? [] {
|
|
let data = try await ServiceManager.run(["logs", service.name, "--profile", profile, "--lines", "30"])
|
|
if let text = String(data: data, encoding: .utf8), !text.isEmpty {
|
|
chunks.append(text)
|
|
}
|
|
}
|
|
copyToPasteboard(chunks.joined(separator: "\n\n"))
|
|
} catch {
|
|
lastError = String(describing: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func openMCPHealth() {
|
|
if let url = URL(string: "http://127.0.0.1:8765/health") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
func openLogsFolder() {
|
|
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs", isDirectory: true))
|
|
}
|
|
|
|
func openProjectKnowledge() {
|
|
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent("project-knowledge", isDirectory: true))
|
|
}
|
|
|
|
func openMattermost() {
|
|
runAction(["start", "mattermost-desktop", "--profile", profile])
|
|
}
|
|
|
|
private func copyToPasteboard(_ value: String) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(value, forType: .string)
|
|
}
|
|
|
|
private func runAction(_ arguments: [String]) {
|
|
Task {
|
|
do {
|
|
_ = try await ServiceManager.run(arguments)
|
|
await refresh()
|
|
} catch {
|
|
lastError = String(describing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ServiceMenuView: View {
|
|
@ObservedObject var model: ServiceStatusModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("AI Workspace")
|
|
.font(.title3.bold())
|
|
Text("Profile: \(model.profile)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Button {
|
|
Task { await model.refresh() }
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("Refresh")
|
|
}
|
|
|
|
Divider()
|
|
|
|
if let report = model.report {
|
|
VStack(spacing: 8) {
|
|
ForEach(report.services) { service in
|
|
ServiceRow(service: service)
|
|
}
|
|
}
|
|
} else if let error = model.lastError {
|
|
Label("Status unavailable", systemImage: "exclamationmark.triangle")
|
|
Text(error)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(3)
|
|
} else {
|
|
Label("Loading status...", systemImage: "hourglass")
|
|
}
|
|
|
|
if let lanIP = model.lanIP {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "wifi")
|
|
.foregroundStyle(.secondary)
|
|
Text("Photo Inbox LAN")
|
|
Spacer()
|
|
Text("http://\(lanIP):8787/upload")
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.textSelection(.enabled)
|
|
}
|
|
|
|
Divider()
|
|
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
|
ActionButton(title: "Start Fidelity", systemImage: "play.fill", action: model.startProfile)
|
|
ActionButton(title: "Stop Fidelity", systemImage: "stop.fill", role: .destructive, action: model.stopProfile)
|
|
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", action: model.restartMCP)
|
|
ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", action: model.openMattermost)
|
|
}
|
|
|
|
Divider()
|
|
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
|
ActionButton(title: "Run Doctor", systemImage: "stethoscope", action: model.runDoctor)
|
|
ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", action: model.copyDoctorJSON)
|
|
ActionButton(title: "Copy Photo URL", systemImage: "link", action: model.copyPhotoInboxURL)
|
|
ActionButton(title: "Copy Logs", systemImage: "doc.text", action: model.copyRecentLogs)
|
|
ActionButton(title: "MCP Health", systemImage: "heart.text.square", action: model.openMCPHealth)
|
|
ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder)
|
|
}
|
|
|
|
ActionButton(title: "Open Project Knowledge", systemImage: "books.vertical", action: model.openProjectKnowledge)
|
|
|
|
Divider()
|
|
HStack {
|
|
if let error = model.lastError {
|
|
Label(error, systemImage: "exclamationmark.triangle")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
.lineLimit(2)
|
|
}
|
|
Spacer()
|
|
Button("Quit") {
|
|
NSApplication.shared.terminate(nil)
|
|
}
|
|
.keyboardShortcut("q")
|
|
}
|
|
}
|
|
.padding(18)
|
|
.frame(width: 430)
|
|
}
|
|
}
|
|
|
|
struct ActionButton: View {
|
|
let title: String
|
|
let systemImage: String
|
|
var role: ButtonRole?
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(role: role, action: action) {
|
|
Label(title, systemImage: systemImage)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
struct ServiceRow: View {
|
|
let service: ServiceStatus
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: symbol)
|
|
.foregroundStyle(color)
|
|
.frame(width: 16, alignment: .center)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(service.displayName)
|
|
.font(.body.weight(.medium))
|
|
Text(service.detail)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 12)
|
|
StatusBadge(text: service.compactStatus, color: color)
|
|
}
|
|
.padding(.vertical, 7)
|
|
.padding(.horizontal, 10)
|
|
.background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
.help(service.detail)
|
|
}
|
|
|
|
private var symbol: String {
|
|
switch service.status {
|
|
case "running": "checkmark.circle"
|
|
case "launcher": "arrow.up.forward.app"
|
|
case "externally running": "link.circle"
|
|
case "unhealthy": "exclamationmark.triangle"
|
|
case "disabled": "minus.circle"
|
|
default: "xmark.circle"
|
|
}
|
|
}
|
|
|
|
private var color: Color {
|
|
switch service.status {
|
|
case "running", "launcher", "externally running": .green
|
|
case "unhealthy": .orange
|
|
case "disabled": .secondary
|
|
default: .red
|
|
}
|
|
}
|
|
}
|
|
|
|
struct StatusBadge: View {
|
|
let text: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
Text(text)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(color)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(color.opacity(0.14), in: Capsule())
|
|
}
|
|
}
|
|
|
|
enum ServiceManager {
|
|
static func run(_ arguments: [String]) async throws -> Data {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
let process = Process()
|
|
process.currentDirectoryURL = workspaceRoot
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
|
process.arguments = ["python3", "scripts/aiw/services.py"] + arguments
|
|
|
|
let output = Pipe()
|
|
let error = Pipe()
|
|
process.standardOutput = output
|
|
process.standardError = error
|
|
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
let data = output.fileHandleForReading.readDataToEndOfFile()
|
|
let errorData = error.fileHandleForReading.readDataToEndOfFile()
|
|
guard process.terminationStatus == 0 else {
|
|
let message = String(data: errorData.isEmpty ? data : errorData, encoding: .utf8) ?? "service command failed"
|
|
throw ServiceManagerError.commandFailed(message.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
return data
|
|
}.value
|
|
}
|
|
}
|
|
|
|
enum NetworkInfo {
|
|
static func primaryLANIP() async -> String? {
|
|
for interface in ["en0", "en1"] {
|
|
if let value = try? await runIPConfig(interface), !value.isEmpty {
|
|
return value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func runIPConfig(_ interface: String) async throws -> String {
|
|
try await Task.detached(priority: .utility) {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/sbin/ipconfig")
|
|
process.arguments = ["getifaddr", interface]
|
|
let output = Pipe()
|
|
process.standardOutput = output
|
|
process.standardError = Pipe()
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
let data = output.fileHandleForReading.readDataToEndOfFile()
|
|
guard process.terminationStatus == 0 else { return "" }
|
|
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
}.value
|
|
}
|
|
}
|
|
|
|
enum ServiceManagerError: LocalizedError {
|
|
case commandFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .commandFailed(let message): message
|
|
}
|
|
}
|
|
}
|
|
|
|
struct StatusReport: Decodable {
|
|
let profile: String
|
|
let workspace: String
|
|
let runtime: String
|
|
let services: [ServiceStatus]
|
|
}
|
|
|
|
struct ServiceStatus: Decodable, Identifiable {
|
|
var id: String { name }
|
|
|
|
let name: String
|
|
let enabled: Bool
|
|
let kind: String
|
|
let status: String
|
|
let pid: Int?
|
|
let command: [String]
|
|
let health: Health
|
|
let state: [String: JSONValue]
|
|
|
|
var displayName: String {
|
|
switch name {
|
|
case "aiw-context-mcp": "Context MCP"
|
|
case "mattermost-proxy": "Mattermost Proxy"
|
|
case "mattermost-desktop": "Mattermost Desktop via Proxy"
|
|
case "photo-inbox": "Photo Inbox"
|
|
default: name
|
|
}
|
|
}
|
|
|
|
var detail: String {
|
|
if name == "mattermost-desktop" {
|
|
return "Launches Mattermost through local proxy 127.0.0.1:8080"
|
|
}
|
|
return health.detail
|
|
}
|
|
|
|
var compactStatus: String {
|
|
switch status {
|
|
case "externally running": "external"
|
|
default: status
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Health: Decodable {
|
|
let ok: Bool?
|
|
let detail: String
|
|
}
|
|
|
|
enum JSONValue: Decodable {
|
|
case string(String)
|
|
case number(Double)
|
|
case bool(Bool)
|
|
case object([String: JSONValue])
|
|
case array([JSONValue])
|
|
case null
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
if container.decodeNil() {
|
|
self = .null
|
|
} else if let value = try? container.decode(Bool.self) {
|
|
self = .bool(value)
|
|
} else if let value = try? container.decode(Double.self) {
|
|
self = .number(value)
|
|
} else if let value = try? container.decode(String.self) {
|
|
self = .string(value)
|
|
} else if let value = try? container.decode([String: JSONValue].self) {
|
|
self = .object(value)
|
|
} else {
|
|
self = .array(try container.decode([JSONValue].self))
|
|
}
|
|
}
|
|
}
|