mageagent-local 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/QUICK_START.md +255 -0
- package/README.md +453 -0
- package/bin/install-launchagent.js +135 -0
- package/bin/mageagent.js +255 -0
- package/bin/postinstall.js +43 -0
- package/config/com.adverant.mageagent.plist +38 -0
- package/config/config.example.json +41 -0
- package/docs/AUTOSTART.md +300 -0
- package/docs/MENUBAR_APP.md +238 -0
- package/docs/PATTERNS.md +501 -0
- package/docs/TROUBLESHOOTING.md +364 -0
- package/docs/VSCODE_SETUP.md +230 -0
- package/docs/assets/mageagent-logo.png +0 -0
- package/docs/assets/mageagent-logo.svg +57 -0
- package/docs/assets/menubar-screenshot.png +0 -0
- package/docs/diagrams/architecture.md +229 -0
- package/mageagent/__init__.py +4 -0
- package/mageagent/server.py +951 -0
- package/mageagent/tool_executor.py +453 -0
- package/menubar-app/MageAgentMenuBar/AppDelegate.swift +1337 -0
- package/menubar-app/MageAgentMenuBar/Info.plist +38 -0
- package/menubar-app/MageAgentMenuBar/main.swift +16 -0
- package/menubar-app/Package.swift +18 -0
- package/menubar-app/build/MageAgentMenuBar.app/Contents/Info.plist +38 -0
- package/menubar-app/build/MageAgentMenuBar.app/Contents/MacOS/MageAgentMenuBar +0 -0
- package/menubar-app/build/MageAgentMenuBar.app/Contents/PkgInfo +1 -0
- package/menubar-app/build/MageAgentMenuBar.app/Contents/Resources/icon.png +0 -0
- package/menubar-app/build.sh +96 -0
- package/package.json +81 -0
- package/scripts/build-dmg.sh +196 -0
- package/scripts/install.sh +641 -0
- package/scripts/mageagent-server.sh +218 -0
|
@@ -0,0 +1,1337 @@
|
|
|
1
|
+
import Cocoa
|
|
2
|
+
import UserNotifications
|
|
3
|
+
|
|
4
|
+
// MARK: - MageAgent Menu Bar Application Delegate
|
|
5
|
+
// Production-grade macOS menu bar application for MageAgent server management
|
|
6
|
+
// Implements NSMenuItemValidation for proper menu item state management
|
|
7
|
+
|
|
8
|
+
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
|
|
9
|
+
|
|
10
|
+
// MARK: - Properties
|
|
11
|
+
|
|
12
|
+
/// Status bar item - retained for entire app lifecycle
|
|
13
|
+
private var statusItem: NSStatusItem!
|
|
14
|
+
|
|
15
|
+
/// Main menu - stored as strong reference
|
|
16
|
+
private var menu: NSMenu!
|
|
17
|
+
|
|
18
|
+
/// Status checking timer
|
|
19
|
+
private var statusTimer: Timer?
|
|
20
|
+
|
|
21
|
+
/// Server status for menu item state management
|
|
22
|
+
private var isServerRunning: Bool = false
|
|
23
|
+
|
|
24
|
+
// MARK: - Menu Item Tags (for identification)
|
|
25
|
+
|
|
26
|
+
private enum MenuItemTag: Int {
|
|
27
|
+
case status = 100
|
|
28
|
+
case models = 200
|
|
29
|
+
case startServer = 300
|
|
30
|
+
case stopServer = 301
|
|
31
|
+
case restartServer = 302
|
|
32
|
+
case warmupModels = 303
|
|
33
|
+
case openDocs = 400
|
|
34
|
+
case viewLogs = 401
|
|
35
|
+
case runTest = 402
|
|
36
|
+
case settings = 500
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// MARK: - Configuration
|
|
40
|
+
|
|
41
|
+
private struct Config {
|
|
42
|
+
static let mageagentScript = "\(NSHomeDirectory())/.claude/scripts/mageagent-server.sh"
|
|
43
|
+
static let mageagentURL = "http://localhost:3457"
|
|
44
|
+
static let logFile = "\(NSHomeDirectory())/.claude/debug/mageagent.log"
|
|
45
|
+
static let debugLogFile = "\(NSHomeDirectory())/.claude/debug/mageagent-menubar-debug.log"
|
|
46
|
+
static let iconPath = "\(NSHomeDirectory())/.claude/mageagent-menubar/icons/icon_18x18@2x.png"
|
|
47
|
+
static let statusCheckInterval: TimeInterval = 10.0
|
|
48
|
+
static let requestTimeout: TimeInterval = 2.0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// MARK: - Model and Pattern Definitions
|
|
52
|
+
|
|
53
|
+
private struct ModelInfo {
|
|
54
|
+
let modelId: String
|
|
55
|
+
let displayName: String
|
|
56
|
+
let memorySize: String
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private struct PatternInfo {
|
|
60
|
+
let patternId: String
|
|
61
|
+
let displayName: String
|
|
62
|
+
let requiredModels: [String] // Model IDs required for this pattern
|
|
63
|
+
let description: String
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Available models that can be loaded
|
|
67
|
+
private let availableModels: [ModelInfo] = [
|
|
68
|
+
ModelInfo(modelId: "mageagent:primary", displayName: "Qwen-72B Q8 (77GB) - Reasoning", memorySize: "77GB"),
|
|
69
|
+
ModelInfo(modelId: "mageagent:tools", displayName: "Hermes-3 8B Q8 (9GB) - Tool Calling", memorySize: "9GB"),
|
|
70
|
+
ModelInfo(modelId: "mageagent:validator", displayName: "Qwen-Coder 7B (5GB) - Fast Validation", memorySize: "5GB"),
|
|
71
|
+
ModelInfo(modelId: "mageagent:competitor", displayName: "Qwen-Coder 32B (18GB) - Coding", memorySize: "18GB")
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
/// Available orchestration patterns with their required models
|
|
75
|
+
private let availablePatterns: [PatternInfo] = [
|
|
76
|
+
PatternInfo(
|
|
77
|
+
patternId: "mageagent:auto",
|
|
78
|
+
displayName: "auto",
|
|
79
|
+
requiredModels: ["mageagent:validator"], // Uses validator for classification, loads others on demand
|
|
80
|
+
description: "Intelligent task routing - classifies task and routes to best model"
|
|
81
|
+
),
|
|
82
|
+
PatternInfo(
|
|
83
|
+
patternId: "mageagent:execute",
|
|
84
|
+
displayName: "execute",
|
|
85
|
+
requiredModels: ["mageagent:primary", "mageagent:tools"],
|
|
86
|
+
description: "Real tool execution - ReAct loop with Qwen-72B + Hermes tool calling"
|
|
87
|
+
),
|
|
88
|
+
PatternInfo(
|
|
89
|
+
patternId: "mageagent:hybrid",
|
|
90
|
+
displayName: "hybrid",
|
|
91
|
+
requiredModels: ["mageagent:primary", "mageagent:tools"],
|
|
92
|
+
description: "Reasoning + tools - Qwen-72B for thinking, Hermes for tool extraction"
|
|
93
|
+
),
|
|
94
|
+
PatternInfo(
|
|
95
|
+
patternId: "mageagent:validated",
|
|
96
|
+
displayName: "validated",
|
|
97
|
+
requiredModels: ["mageagent:primary", "mageagent:validator"],
|
|
98
|
+
description: "Generate + validate - Qwen-72B generates, 7B validates and revises"
|
|
99
|
+
),
|
|
100
|
+
PatternInfo(
|
|
101
|
+
patternId: "mageagent:compete",
|
|
102
|
+
displayName: "compete",
|
|
103
|
+
requiredModels: ["mageagent:primary", "mageagent:competitor", "mageagent:validator"],
|
|
104
|
+
description: "Multi-model judge - 72B + 32B generate, 7B judges best response"
|
|
105
|
+
)
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
/// Track which models are currently loaded
|
|
109
|
+
private var loadedModels: Set<String> = []
|
|
110
|
+
|
|
111
|
+
/// Currently selected pattern
|
|
112
|
+
private var selectedPattern: String = "mageagent:auto"
|
|
113
|
+
|
|
114
|
+
// MARK: - Lifecycle
|
|
115
|
+
|
|
116
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
117
|
+
debugLog("Application launched - initializing MageAgent Menu Bar")
|
|
118
|
+
|
|
119
|
+
// Configure as menu bar only app (no dock icon)
|
|
120
|
+
NSApp.setActivationPolicy(.accessory)
|
|
121
|
+
|
|
122
|
+
// Request notification permissions
|
|
123
|
+
requestNotificationPermission()
|
|
124
|
+
|
|
125
|
+
// Initialize UI components
|
|
126
|
+
setupStatusItem()
|
|
127
|
+
setupMenu()
|
|
128
|
+
|
|
129
|
+
// Start monitoring server status
|
|
130
|
+
checkServerStatus()
|
|
131
|
+
startStatusTimer()
|
|
132
|
+
|
|
133
|
+
debugLog("Application initialization complete")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func applicationWillTerminate(_ notification: Notification) {
|
|
137
|
+
debugLog("Application terminating - cleaning up")
|
|
138
|
+
statusTimer?.invalidate()
|
|
139
|
+
statusTimer = nil
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// MARK: - UI Setup
|
|
143
|
+
|
|
144
|
+
private func setupStatusItem() {
|
|
145
|
+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
146
|
+
|
|
147
|
+
guard let button = statusItem.button else {
|
|
148
|
+
debugLog("ERROR: Failed to get status item button")
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Load custom icon or fall back to system symbol
|
|
153
|
+
if let iconImage = NSImage(contentsOfFile: Config.iconPath) {
|
|
154
|
+
iconImage.size = NSSize(width: 25, height: 25)
|
|
155
|
+
iconImage.isTemplate = true // Adapts to light/dark mode
|
|
156
|
+
button.image = iconImage
|
|
157
|
+
debugLog("Custom icon loaded successfully")
|
|
158
|
+
} else if let symbolImage = NSImage(systemSymbolName: "brain.head.profile", accessibilityDescription: "MageAgent") {
|
|
159
|
+
symbolImage.isTemplate = true
|
|
160
|
+
button.image = symbolImage
|
|
161
|
+
debugLog("Using fallback system symbol")
|
|
162
|
+
} else {
|
|
163
|
+
// Ultimate fallback - text
|
|
164
|
+
button.title = "MA"
|
|
165
|
+
debugLog("Using text fallback for status item")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
button.toolTip = "MageAgent - Multi-Model AI Orchestration"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private func setupMenu() {
|
|
172
|
+
menu = NSMenu()
|
|
173
|
+
menu.autoenablesItems = false // We manage enabled state manually
|
|
174
|
+
|
|
175
|
+
// Status display (non-interactive)
|
|
176
|
+
let statusMenuItem = NSMenuItem(title: "Status: Checking...", action: nil, keyEquivalent: "")
|
|
177
|
+
statusMenuItem.tag = MenuItemTag.status.rawValue
|
|
178
|
+
statusMenuItem.isEnabled = false
|
|
179
|
+
menu.addItem(statusMenuItem)
|
|
180
|
+
|
|
181
|
+
menu.addItem(NSMenuItem.separator())
|
|
182
|
+
|
|
183
|
+
// Server control section
|
|
184
|
+
addMenuItem(title: "Start Server", action: #selector(startServerAction(_:)),
|
|
185
|
+
keyEquivalent: "s", tag: .startServer)
|
|
186
|
+
addMenuItem(title: "Stop Server", action: #selector(stopServerAction(_:)),
|
|
187
|
+
keyEquivalent: "", tag: .stopServer)
|
|
188
|
+
addMenuItem(title: "Restart Server", action: #selector(restartServerAction(_:)),
|
|
189
|
+
keyEquivalent: "r", tag: .restartServer)
|
|
190
|
+
addMenuItem(title: "Warmup Models", action: #selector(warmupModelsAction(_:)),
|
|
191
|
+
keyEquivalent: "w", tag: .warmupModels)
|
|
192
|
+
|
|
193
|
+
menu.addItem(NSMenuItem.separator())
|
|
194
|
+
|
|
195
|
+
// Models submenu - clickable to load individual models
|
|
196
|
+
let modelsItem = NSMenuItem(title: "Load Models", action: nil, keyEquivalent: "")
|
|
197
|
+
modelsItem.tag = MenuItemTag.models.rawValue
|
|
198
|
+
let modelsSubmenu = NSMenu()
|
|
199
|
+
|
|
200
|
+
// Add header
|
|
201
|
+
let headerItem = NSMenuItem(title: "Click to load into memory:", action: nil, keyEquivalent: "")
|
|
202
|
+
headerItem.isEnabled = false
|
|
203
|
+
modelsSubmenu.addItem(headerItem)
|
|
204
|
+
modelsSubmenu.addItem(NSMenuItem.separator())
|
|
205
|
+
|
|
206
|
+
// Add each model as a clickable item
|
|
207
|
+
for (index, model) in availableModels.enumerated() {
|
|
208
|
+
let item = NSMenuItem(title: model.displayName, action: #selector(loadModelAction(_:)), keyEquivalent: "")
|
|
209
|
+
item.target = self
|
|
210
|
+
item.tag = 1000 + index // Tags starting at 1000 for models
|
|
211
|
+
item.representedObject = model.modelId
|
|
212
|
+
modelsSubmenu.addItem(item)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
modelsSubmenu.addItem(NSMenuItem.separator())
|
|
216
|
+
|
|
217
|
+
// Load all models option
|
|
218
|
+
let loadAllItem = NSMenuItem(title: "Load All Models", action: #selector(warmupModelsAction(_:)), keyEquivalent: "w")
|
|
219
|
+
loadAllItem.target = self
|
|
220
|
+
loadAllItem.keyEquivalentModifierMask = [.command]
|
|
221
|
+
modelsSubmenu.addItem(loadAllItem)
|
|
222
|
+
|
|
223
|
+
modelsItem.submenu = modelsSubmenu
|
|
224
|
+
menu.addItem(modelsItem)
|
|
225
|
+
|
|
226
|
+
// Patterns submenu - each pattern shows required models as a submenu
|
|
227
|
+
let patternsItem = NSMenuItem(title: "Patterns", action: nil, keyEquivalent: "")
|
|
228
|
+
let patternsSubmenu = NSMenu()
|
|
229
|
+
|
|
230
|
+
// Add header
|
|
231
|
+
let patternHeader = NSMenuItem(title: "Select pattern (click to load required models):", action: nil, keyEquivalent: "")
|
|
232
|
+
patternHeader.isEnabled = false
|
|
233
|
+
patternsSubmenu.addItem(patternHeader)
|
|
234
|
+
patternsSubmenu.addItem(NSMenuItem.separator())
|
|
235
|
+
|
|
236
|
+
// Add each pattern with its own submenu showing required models
|
|
237
|
+
for (index, pattern) in availablePatterns.enumerated() {
|
|
238
|
+
let patternItem = NSMenuItem(title: pattern.displayName, action: nil, keyEquivalent: "")
|
|
239
|
+
patternItem.tag = 2000 + index
|
|
240
|
+
|
|
241
|
+
// Create submenu for this pattern
|
|
242
|
+
let patternDetailsSubmenu = NSMenu()
|
|
243
|
+
|
|
244
|
+
// Description header
|
|
245
|
+
let descItem = NSMenuItem(title: pattern.description, action: nil, keyEquivalent: "")
|
|
246
|
+
descItem.isEnabled = false
|
|
247
|
+
patternDetailsSubmenu.addItem(descItem)
|
|
248
|
+
patternDetailsSubmenu.addItem(NSMenuItem.separator())
|
|
249
|
+
|
|
250
|
+
// Required models header
|
|
251
|
+
let reqHeader = NSMenuItem(title: "Required Models:", action: nil, keyEquivalent: "")
|
|
252
|
+
reqHeader.isEnabled = false
|
|
253
|
+
patternDetailsSubmenu.addItem(reqHeader)
|
|
254
|
+
|
|
255
|
+
// List each required model
|
|
256
|
+
for modelId in pattern.requiredModels {
|
|
257
|
+
if let modelInfo = availableModels.first(where: { $0.modelId == modelId }) {
|
|
258
|
+
let modelItem = NSMenuItem(title: " • \(modelInfo.displayName)", action: nil, keyEquivalent: "")
|
|
259
|
+
modelItem.isEnabled = false
|
|
260
|
+
patternDetailsSubmenu.addItem(modelItem)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
patternDetailsSubmenu.addItem(NSMenuItem.separator())
|
|
265
|
+
|
|
266
|
+
// "Use this pattern" action - loads all required models
|
|
267
|
+
let usePatternItem = NSMenuItem(title: "Use \(pattern.displayName) Pattern", action: #selector(selectPatternAction(_:)), keyEquivalent: "")
|
|
268
|
+
usePatternItem.target = self
|
|
269
|
+
usePatternItem.representedObject = pattern
|
|
270
|
+
patternDetailsSubmenu.addItem(usePatternItem)
|
|
271
|
+
|
|
272
|
+
patternItem.submenu = patternDetailsSubmenu
|
|
273
|
+
patternsSubmenu.addItem(patternItem)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
patternsItem.submenu = patternsSubmenu
|
|
277
|
+
menu.addItem(patternsItem)
|
|
278
|
+
|
|
279
|
+
menu.addItem(NSMenuItem.separator())
|
|
280
|
+
|
|
281
|
+
// Utility actions
|
|
282
|
+
addMenuItem(title: "Open API Docs", action: #selector(openDocsAction(_:)),
|
|
283
|
+
keyEquivalent: "d", tag: .openDocs)
|
|
284
|
+
addMenuItem(title: "View Logs", action: #selector(viewLogsAction(_:)),
|
|
285
|
+
keyEquivalent: "l", tag: .viewLogs)
|
|
286
|
+
addMenuItem(title: "Run Test", action: #selector(runTestAction(_:)),
|
|
287
|
+
keyEquivalent: "t", tag: .runTest)
|
|
288
|
+
|
|
289
|
+
menu.addItem(NSMenuItem.separator())
|
|
290
|
+
|
|
291
|
+
// Settings
|
|
292
|
+
addMenuItem(title: "Settings...", action: #selector(showSettingsAction(_:)),
|
|
293
|
+
keyEquivalent: ",", tag: .settings)
|
|
294
|
+
|
|
295
|
+
menu.addItem(NSMenuItem.separator())
|
|
296
|
+
|
|
297
|
+
// Quit
|
|
298
|
+
let quitItem = NSMenuItem(title: "Quit MageAgent Menu", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
|
299
|
+
menu.addItem(quitItem)
|
|
300
|
+
|
|
301
|
+
// Assign menu to status item
|
|
302
|
+
statusItem.menu = menu
|
|
303
|
+
|
|
304
|
+
debugLog("Menu setup complete with \(menu.items.count) items")
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/// Helper to create menu items with proper target/action binding
|
|
308
|
+
private func addMenuItem(title: String, action: Selector, keyEquivalent: String, tag: MenuItemTag) {
|
|
309
|
+
let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
|
|
310
|
+
item.target = self
|
|
311
|
+
item.tag = tag.rawValue
|
|
312
|
+
if !keyEquivalent.isEmpty {
|
|
313
|
+
item.keyEquivalentModifierMask = [.command]
|
|
314
|
+
}
|
|
315
|
+
menu.addItem(item)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// MARK: - NSMenuItemValidation
|
|
319
|
+
|
|
320
|
+
/// Called by AppKit to determine if menu items should be enabled
|
|
321
|
+
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
|
322
|
+
guard let tag = MenuItemTag(rawValue: menuItem.tag) else {
|
|
323
|
+
return true // Allow untagged items
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
switch tag {
|
|
327
|
+
case .startServer:
|
|
328
|
+
return !isServerRunning
|
|
329
|
+
case .stopServer, .restartServer, .warmupModels:
|
|
330
|
+
return isServerRunning
|
|
331
|
+
case .openDocs:
|
|
332
|
+
return isServerRunning
|
|
333
|
+
case .viewLogs, .runTest, .settings:
|
|
334
|
+
return true
|
|
335
|
+
default:
|
|
336
|
+
return true
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// MARK: - Server Control Actions
|
|
341
|
+
|
|
342
|
+
@objc func startServerAction(_ sender: NSMenuItem) {
|
|
343
|
+
debugLog("Start Server action triggered")
|
|
344
|
+
executeServerCommand("start", successMessage: "Server started on port 3457",
|
|
345
|
+
failureMessage: "Failed to start server")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
@objc func stopServerAction(_ sender: NSMenuItem) {
|
|
349
|
+
debugLog("Stop Server action triggered")
|
|
350
|
+
executeServerCommand("stop", successMessage: "Server stopped",
|
|
351
|
+
failureMessage: "Failed to stop server")
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
@objc func restartServerAction(_ sender: NSMenuItem) {
|
|
355
|
+
debugLog("Restart Server action triggered")
|
|
356
|
+
executeServerCommand("restart", successMessage: "Server restarted",
|
|
357
|
+
failureMessage: "Failed to restart server")
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
@objc func warmupModelsAction(_ sender: NSMenuItem) {
|
|
361
|
+
debugLog("Warmup Models action triggered")
|
|
362
|
+
sendNotification(title: "MageAgent", body: "Warming up models - this may take a few minutes...")
|
|
363
|
+
|
|
364
|
+
// Models to warm up - each will load into GPU/RAM
|
|
365
|
+
let modelsToWarmup = [
|
|
366
|
+
("mageagent:primary", "Qwen-72B Q8 (77GB)"),
|
|
367
|
+
("mageagent:tools", "Hermes-3 8B Q8 (9GB)"),
|
|
368
|
+
("mageagent:validator", "Qwen-Coder 7B (5GB)"),
|
|
369
|
+
("mageagent:competitor", "Qwen-Coder 32B (18GB)")
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
// Warm up each model sequentially
|
|
373
|
+
warmupModels(modelsToWarmup, index: 0)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// MARK: - Model Loading Actions
|
|
377
|
+
|
|
378
|
+
@objc func loadModelAction(_ sender: NSMenuItem) {
|
|
379
|
+
guard let modelId = sender.representedObject as? String else {
|
|
380
|
+
debugLog("ERROR: loadModelAction - no modelId in representedObject")
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Find model info
|
|
385
|
+
let modelName = availableModels.first { $0.modelId == modelId }?.displayName ?? modelId
|
|
386
|
+
debugLog("Load Model action triggered for: \(modelName)")
|
|
387
|
+
|
|
388
|
+
// Show loading notification
|
|
389
|
+
sendNotification(title: "MageAgent", body: "Loading \(modelName)...")
|
|
390
|
+
|
|
391
|
+
// Update status to show loading
|
|
392
|
+
DispatchQueue.main.async {
|
|
393
|
+
if let statusItem = self.menu.item(withTag: MenuItemTag.status.rawValue) {
|
|
394
|
+
statusItem.title = "Loading: \(modelName)..."
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Load the model via warmup request
|
|
399
|
+
warmupModel(modelId: modelId) { [weak self] success in
|
|
400
|
+
guard let self = self else { return }
|
|
401
|
+
|
|
402
|
+
if success {
|
|
403
|
+
self.loadedModels.insert(modelId)
|
|
404
|
+
self.sendNotification(title: "MageAgent", body: "\(modelName) loaded successfully!")
|
|
405
|
+
self.debugLog("Model \(modelName) loaded into memory")
|
|
406
|
+
|
|
407
|
+
// Update menu item to show checkmark
|
|
408
|
+
DispatchQueue.main.async {
|
|
409
|
+
sender.state = .on
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
self.sendNotification(title: "MageAgent", body: "Failed to load \(modelName)")
|
|
413
|
+
self.debugLog("Failed to load model \(modelName)")
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Restore status display
|
|
417
|
+
self.checkServerStatus()
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// MARK: - Pattern Selection Actions
|
|
422
|
+
|
|
423
|
+
@objc func selectPatternAction(_ sender: NSMenuItem) {
|
|
424
|
+
guard let pattern = sender.representedObject as? PatternInfo else {
|
|
425
|
+
debugLog("ERROR: selectPatternAction - no PatternInfo in representedObject")
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
debugLog("Pattern selection triggered: \(pattern.displayName)")
|
|
430
|
+
|
|
431
|
+
// Update selected pattern
|
|
432
|
+
selectedPattern = pattern.patternId
|
|
433
|
+
|
|
434
|
+
// Check which required models are not yet loaded
|
|
435
|
+
let missingModels = pattern.requiredModels.filter { !loadedModels.contains($0) }
|
|
436
|
+
|
|
437
|
+
if missingModels.isEmpty {
|
|
438
|
+
// All models already loaded
|
|
439
|
+
sendNotification(title: "MageAgent", body: "Pattern '\(pattern.displayName)' active - all required models already loaded!")
|
|
440
|
+
updatePatternMenuCheckmarks(selectedPatternId: pattern.patternId)
|
|
441
|
+
} else {
|
|
442
|
+
// Need to load missing models
|
|
443
|
+
let modelNames = missingModels.compactMap { modelId in
|
|
444
|
+
availableModels.first { $0.modelId == modelId }?.displayName
|
|
445
|
+
}.joined(separator: ", ")
|
|
446
|
+
|
|
447
|
+
sendNotification(title: "MageAgent", body: "Loading models for '\(pattern.displayName)':\n\(modelNames)")
|
|
448
|
+
|
|
449
|
+
// Load missing models sequentially
|
|
450
|
+
loadModelsForPattern(pattern: pattern, modelIds: missingModels, index: 0)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/// Load required models for a pattern sequentially
|
|
455
|
+
private func loadModelsForPattern(pattern: PatternInfo, modelIds: [String], index: Int) {
|
|
456
|
+
guard index < modelIds.count else {
|
|
457
|
+
// All models loaded
|
|
458
|
+
debugLog("All models for pattern '\(pattern.displayName)' loaded successfully")
|
|
459
|
+
sendNotification(title: "MageAgent", body: "Pattern '\(pattern.displayName)' ready!")
|
|
460
|
+
updatePatternMenuCheckmarks(selectedPatternId: pattern.patternId)
|
|
461
|
+
checkServerStatus()
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let modelId = modelIds[index]
|
|
466
|
+
let modelName = availableModels.first { $0.modelId == modelId }?.displayName ?? modelId
|
|
467
|
+
|
|
468
|
+
// Update status
|
|
469
|
+
DispatchQueue.main.async {
|
|
470
|
+
if let statusItem = self.menu.item(withTag: MenuItemTag.status.rawValue) {
|
|
471
|
+
statusItem.title = "Loading: \(modelName)..."
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
debugLog("Loading model \(index + 1)/\(modelIds.count) for pattern: \(modelName)")
|
|
476
|
+
|
|
477
|
+
warmupModel(modelId: modelId) { [weak self] success in
|
|
478
|
+
guard let self = self else { return }
|
|
479
|
+
|
|
480
|
+
if success {
|
|
481
|
+
self.loadedModels.insert(modelId)
|
|
482
|
+
self.debugLog("Model \(modelName) loaded for pattern")
|
|
483
|
+
|
|
484
|
+
// Update checkmark on model menu item
|
|
485
|
+
self.updateModelMenuCheckmark(modelId: modelId, loaded: true)
|
|
486
|
+
} else {
|
|
487
|
+
self.debugLog("Failed to load model \(modelName) for pattern")
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Continue to next model
|
|
491
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
492
|
+
self.loadModelsForPattern(pattern: pattern, modelIds: modelIds, index: index + 1)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/// Update checkmark on a model menu item
|
|
498
|
+
private func updateModelMenuCheckmark(modelId: String, loaded: Bool) {
|
|
499
|
+
if let modelsItem = menu.item(withTag: MenuItemTag.models.rawValue),
|
|
500
|
+
let submenu = modelsItem.submenu {
|
|
501
|
+
for item in submenu.items {
|
|
502
|
+
if let itemModelId = item.representedObject as? String, itemModelId == modelId {
|
|
503
|
+
item.state = loaded ? .on : .off
|
|
504
|
+
break
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/// Update checkmarks on pattern menu items
|
|
511
|
+
private func updatePatternMenuCheckmarks(selectedPatternId: String) {
|
|
512
|
+
if let patternsItem = findPatternsMenuItem(),
|
|
513
|
+
let submenu = patternsItem.submenu {
|
|
514
|
+
for item in submenu.items {
|
|
515
|
+
// Each pattern item has a submenu with details
|
|
516
|
+
if let patternSubmenu = item.submenu {
|
|
517
|
+
// Look for "Use X Pattern" item
|
|
518
|
+
for subItem in patternSubmenu.items {
|
|
519
|
+
if let pattern = subItem.representedObject as? PatternInfo {
|
|
520
|
+
subItem.state = (pattern.patternId == selectedPatternId) ? .on : .off
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Also check the parent pattern item
|
|
525
|
+
let patternId = availablePatterns.first { $0.displayName == item.title }?.patternId
|
|
526
|
+
item.state = (patternId == selectedPatternId) ? .on : .off
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/// Find the Patterns menu item by searching menu items
|
|
532
|
+
private func findPatternsMenuItem() -> NSMenuItem? {
|
|
533
|
+
for item in menu.items {
|
|
534
|
+
if item.title == "Patterns" {
|
|
535
|
+
return item
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return nil
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private func warmupModels(_ models: [(String, String)], index: Int) {
|
|
542
|
+
guard index < models.count else {
|
|
543
|
+
// All models warmed up
|
|
544
|
+
debugLog("All models warmed up successfully")
|
|
545
|
+
sendNotification(title: "MageAgent", body: "All models loaded into memory!")
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let (modelId, modelName) = models[index]
|
|
550
|
+
debugLog("Warming up model \(index + 1)/\(models.count): \(modelName)")
|
|
551
|
+
|
|
552
|
+
// Update status to show progress
|
|
553
|
+
DispatchQueue.main.async {
|
|
554
|
+
if let statusItem = self.menu.item(withTag: MenuItemTag.status.rawValue) {
|
|
555
|
+
statusItem.title = "Loading: \(modelName)..."
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Send a minimal inference request to load the model
|
|
560
|
+
warmupModel(modelId: modelId) { [weak self] success in
|
|
561
|
+
guard let self = self else { return }
|
|
562
|
+
|
|
563
|
+
if success {
|
|
564
|
+
self.debugLog("Model \(modelName) loaded successfully")
|
|
565
|
+
} else {
|
|
566
|
+
self.debugLog("Failed to load model \(modelName)")
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Continue to next model after a brief delay
|
|
570
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
571
|
+
self.warmupModels(models, index: index + 1)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private func warmupModel(modelId: String, completion: @escaping (Bool) -> Void) {
|
|
577
|
+
guard let url = URL(string: "\(Config.mageagentURL)/v1/chat/completions") else {
|
|
578
|
+
completion(false)
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
var request = URLRequest(url: url)
|
|
583
|
+
request.httpMethod = "POST"
|
|
584
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
585
|
+
request.timeoutInterval = 300 // 5 minutes for large models
|
|
586
|
+
|
|
587
|
+
// Minimal request to trigger model loading
|
|
588
|
+
let body: [String: Any] = [
|
|
589
|
+
"model": modelId,
|
|
590
|
+
"messages": [
|
|
591
|
+
["role": "user", "content": "Hi"]
|
|
592
|
+
],
|
|
593
|
+
"max_tokens": 5,
|
|
594
|
+
"temperature": 0.1
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
do {
|
|
598
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
599
|
+
} catch {
|
|
600
|
+
debugLog("Failed to create warmup request: \(error)")
|
|
601
|
+
completion(false)
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
|
606
|
+
DispatchQueue.main.async {
|
|
607
|
+
if let error = error {
|
|
608
|
+
self?.debugLog("Warmup request failed: \(error.localizedDescription)")
|
|
609
|
+
completion(false)
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
614
|
+
completion(false)
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
self?.debugLog("Warmup response status: \(httpResponse.statusCode)")
|
|
619
|
+
completion(httpResponse.statusCode == 200)
|
|
620
|
+
|
|
621
|
+
// Restore status display
|
|
622
|
+
self?.checkServerStatus()
|
|
623
|
+
}
|
|
624
|
+
}.resume()
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private func executeServerCommand(_ command: String, successMessage: String, failureMessage: String) {
|
|
628
|
+
runServerScript(command) { [weak self] success, output in
|
|
629
|
+
guard let self = self else { return }
|
|
630
|
+
|
|
631
|
+
if success {
|
|
632
|
+
self.sendNotification(title: "MageAgent", body: successMessage)
|
|
633
|
+
// Delay status check to allow server to start/stop
|
|
634
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
635
|
+
self.checkServerStatus()
|
|
636
|
+
}
|
|
637
|
+
} else {
|
|
638
|
+
let errorDetail = output.isEmpty ? "" : "\n\(output.prefix(200))"
|
|
639
|
+
self.sendNotification(title: "MageAgent", body: "\(failureMessage)\(errorDetail)")
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// MARK: - Utility Actions
|
|
645
|
+
|
|
646
|
+
@objc func openDocsAction(_ sender: NSMenuItem) {
|
|
647
|
+
debugLog("Open API Docs action triggered")
|
|
648
|
+
guard let url = URL(string: "\(Config.mageagentURL)/docs") else {
|
|
649
|
+
debugLog("ERROR: Invalid docs URL")
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
NSWorkspace.shared.open(url)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
@objc func viewLogsAction(_ sender: NSMenuItem) {
|
|
656
|
+
debugLog("View Logs action triggered")
|
|
657
|
+
|
|
658
|
+
let logURL = URL(fileURLWithPath: Config.logFile)
|
|
659
|
+
|
|
660
|
+
// Try to open with Console.app first
|
|
661
|
+
if FileManager.default.fileExists(atPath: Config.logFile) {
|
|
662
|
+
NSWorkspace.shared.open(logURL)
|
|
663
|
+
} else {
|
|
664
|
+
// Create empty log file if it doesn't exist
|
|
665
|
+
let logDir = (Config.logFile as NSString).deletingLastPathComponent
|
|
666
|
+
try? FileManager.default.createDirectory(atPath: logDir, withIntermediateDirectories: true)
|
|
667
|
+
FileManager.default.createFile(atPath: Config.logFile, contents: nil)
|
|
668
|
+
NSWorkspace.shared.open(logURL)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
@objc func runTestAction(_ sender: NSMenuItem) {
|
|
673
|
+
debugLog("Run Test action triggered")
|
|
674
|
+
showTestResultsWindow()
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// MARK: - Test Results Window
|
|
678
|
+
|
|
679
|
+
/// Reference to keep test window alive
|
|
680
|
+
private var testResultsWindow: NSWindow?
|
|
681
|
+
private var testResultsTextView: NSTextView?
|
|
682
|
+
|
|
683
|
+
/// Show streaming test results in a window
|
|
684
|
+
private func showTestResultsWindow() {
|
|
685
|
+
// Create window
|
|
686
|
+
let window = NSWindow(
|
|
687
|
+
contentRect: NSRect(x: 0, y: 0, width: 600, height: 450),
|
|
688
|
+
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
689
|
+
backing: .buffered,
|
|
690
|
+
defer: false
|
|
691
|
+
)
|
|
692
|
+
window.title = "MageAgent Test Results"
|
|
693
|
+
window.center()
|
|
694
|
+
window.isReleasedWhenClosed = false
|
|
695
|
+
|
|
696
|
+
// Create scroll view with text view
|
|
697
|
+
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 50, width: 600, height: 400))
|
|
698
|
+
scrollView.hasVerticalScroller = true
|
|
699
|
+
scrollView.hasHorizontalScroller = false
|
|
700
|
+
scrollView.autoresizingMask = [.width, .height]
|
|
701
|
+
scrollView.borderType = .noBorder
|
|
702
|
+
|
|
703
|
+
let textView = NSTextView(frame: scrollView.bounds)
|
|
704
|
+
textView.isEditable = false
|
|
705
|
+
textView.isSelectable = true
|
|
706
|
+
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
|
707
|
+
textView.backgroundColor = NSColor.textBackgroundColor
|
|
708
|
+
textView.textColor = NSColor.textColor
|
|
709
|
+
textView.autoresizingMask = [.width]
|
|
710
|
+
textView.isVerticallyResizable = true
|
|
711
|
+
textView.isHorizontallyResizable = false
|
|
712
|
+
textView.textContainer?.widthTracksTextView = true
|
|
713
|
+
textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude)
|
|
714
|
+
|
|
715
|
+
scrollView.documentView = textView
|
|
716
|
+
window.contentView?.addSubview(scrollView)
|
|
717
|
+
|
|
718
|
+
// Create bottom bar with status and close button
|
|
719
|
+
let bottomBar = NSView(frame: NSRect(x: 0, y: 0, width: 600, height: 50))
|
|
720
|
+
bottomBar.autoresizingMask = [.width, .maxYMargin]
|
|
721
|
+
|
|
722
|
+
let statusLabel = NSTextField(labelWithString: "Running tests...")
|
|
723
|
+
statusLabel.frame = NSRect(x: 15, y: 15, width: 400, height: 20)
|
|
724
|
+
statusLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
|
|
725
|
+
statusLabel.tag = 9999 // Tag to find later
|
|
726
|
+
bottomBar.addSubview(statusLabel)
|
|
727
|
+
|
|
728
|
+
let closeButton = NSButton(title: "Close", target: self, action: #selector(closeTestWindow(_:)))
|
|
729
|
+
closeButton.frame = NSRect(x: 500, y: 10, width: 80, height: 30)
|
|
730
|
+
closeButton.bezelStyle = .rounded
|
|
731
|
+
closeButton.autoresizingMask = [.minXMargin]
|
|
732
|
+
bottomBar.addSubview(closeButton)
|
|
733
|
+
|
|
734
|
+
window.contentView?.addSubview(bottomBar)
|
|
735
|
+
|
|
736
|
+
// Store references
|
|
737
|
+
testResultsWindow = window
|
|
738
|
+
testResultsTextView = textView
|
|
739
|
+
|
|
740
|
+
// Show window
|
|
741
|
+
window.makeKeyAndOrderFront(nil)
|
|
742
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
743
|
+
|
|
744
|
+
// Start running tests
|
|
745
|
+
runStreamingTests()
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
@objc private func closeTestWindow(_ sender: Any) {
|
|
749
|
+
testResultsWindow?.close()
|
|
750
|
+
testResultsWindow = nil
|
|
751
|
+
testResultsTextView = nil
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/// Append text to test results with optional color
|
|
755
|
+
private func appendTestResult(_ text: String, color: NSColor = .textColor) {
|
|
756
|
+
guard let textView = testResultsTextView else { return }
|
|
757
|
+
|
|
758
|
+
DispatchQueue.main.async {
|
|
759
|
+
let attributedString = NSAttributedString(
|
|
760
|
+
string: text,
|
|
761
|
+
attributes: [
|
|
762
|
+
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
|
|
763
|
+
.foregroundColor: color
|
|
764
|
+
]
|
|
765
|
+
)
|
|
766
|
+
textView.textStorage?.append(attributedString)
|
|
767
|
+
textView.scrollToEndOfDocument(nil)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/// Update status label in test window
|
|
772
|
+
private func updateTestStatus(_ status: String, color: NSColor = .labelColor) {
|
|
773
|
+
DispatchQueue.main.async {
|
|
774
|
+
if let bottomBar = self.testResultsWindow?.contentView?.subviews.first(where: { $0.frame.height == 50 }),
|
|
775
|
+
let statusLabel = bottomBar.viewWithTag(9999) as? NSTextField {
|
|
776
|
+
statusLabel.stringValue = status
|
|
777
|
+
statusLabel.textColor = color
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/// Run tests with streaming output
|
|
783
|
+
private func runStreamingTests() {
|
|
784
|
+
appendTestResult("MageAgent Test Suite\n", color: .systemBlue)
|
|
785
|
+
appendTestResult("=" .padding(toLength: 50, withPad: "=", startingAt: 0) + "\n\n")
|
|
786
|
+
|
|
787
|
+
// Define test cases
|
|
788
|
+
let tests: [(name: String, test: (@escaping (Bool, String) -> Void) -> Void)] = [
|
|
789
|
+
("Server Health Check", testServerHealth),
|
|
790
|
+
("Models Endpoint", testModelsEndpoint),
|
|
791
|
+
("Validator Model (7B)", { self.testModel("mageagent:validator", completion: $0) }),
|
|
792
|
+
("Tools Model (Hermes-3)", { self.testModel("mageagent:tools", completion: $0) }),
|
|
793
|
+
("Primary Model (72B)", { self.testModel("mageagent:primary", completion: $0) }),
|
|
794
|
+
("Competitor Model (32B)", { self.testModel("mageagent:competitor", completion: $0) })
|
|
795
|
+
]
|
|
796
|
+
|
|
797
|
+
runTestSequence(tests: tests, index: 0, passed: 0, failed: 0)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/// Run tests sequentially
|
|
801
|
+
private func runTestSequence(tests: [(name: String, test: (@escaping (Bool, String) -> Void) -> Void)], index: Int, passed: Int, failed: Int) {
|
|
802
|
+
guard index < tests.count else {
|
|
803
|
+
// All tests complete
|
|
804
|
+
appendTestResult("\n" + "=".padding(toLength: 50, withPad: "=", startingAt: 0) + "\n")
|
|
805
|
+
|
|
806
|
+
let total = passed + failed
|
|
807
|
+
let summary = "Results: \(passed)/\(total) passed"
|
|
808
|
+
|
|
809
|
+
if failed == 0 {
|
|
810
|
+
appendTestResult("All tests passed!\n", color: .systemGreen)
|
|
811
|
+
updateTestStatus(summary, color: .systemGreen)
|
|
812
|
+
sendNotification(title: "MageAgent Tests", body: "All \(total) tests passed!")
|
|
813
|
+
} else {
|
|
814
|
+
appendTestResult("\(failed) test(s) failed\n", color: .systemRed)
|
|
815
|
+
updateTestStatus(summary, color: .systemRed)
|
|
816
|
+
sendNotification(title: "MageAgent Tests", body: "\(failed) of \(total) tests failed")
|
|
817
|
+
}
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
let (name, testFunc) = tests[index]
|
|
822
|
+
appendTestResult("[\(index + 1)/\(tests.count)] Testing: \(name)... ")
|
|
823
|
+
updateTestStatus("Running: \(name)...")
|
|
824
|
+
|
|
825
|
+
testFunc { [weak self] success, message in
|
|
826
|
+
guard let self = self else { return }
|
|
827
|
+
|
|
828
|
+
if success {
|
|
829
|
+
self.appendTestResult("PASS\n", color: .systemGreen)
|
|
830
|
+
if !message.isEmpty {
|
|
831
|
+
self.appendTestResult(" \(message)\n", color: .secondaryLabelColor)
|
|
832
|
+
}
|
|
833
|
+
self.runTestSequence(tests: tests, index: index + 1, passed: passed + 1, failed: failed)
|
|
834
|
+
} else {
|
|
835
|
+
self.appendTestResult("FAIL\n", color: .systemRed)
|
|
836
|
+
if !message.isEmpty {
|
|
837
|
+
self.appendTestResult(" Error: \(message)\n", color: .systemRed)
|
|
838
|
+
}
|
|
839
|
+
self.runTestSequence(tests: tests, index: index + 1, passed: passed, failed: failed + 1)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// MARK: - Individual Test Functions
|
|
845
|
+
|
|
846
|
+
private func testServerHealth(completion: @escaping (Bool, String) -> Void) {
|
|
847
|
+
guard let url = URL(string: "\(Config.mageagentURL)/health") else {
|
|
848
|
+
completion(false, "Invalid URL")
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
var request = URLRequest(url: url)
|
|
853
|
+
request.timeoutInterval = 5.0
|
|
854
|
+
|
|
855
|
+
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
856
|
+
DispatchQueue.main.async {
|
|
857
|
+
if let error = error {
|
|
858
|
+
completion(false, error.localizedDescription)
|
|
859
|
+
return
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
863
|
+
completion(false, "No HTTP response")
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if httpResponse.statusCode == 200 {
|
|
868
|
+
if let data = data,
|
|
869
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
870
|
+
let status = json["status"] as? String {
|
|
871
|
+
completion(true, "Status: \(status)")
|
|
872
|
+
} else {
|
|
873
|
+
completion(true, "")
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
completion(false, "HTTP \(httpResponse.statusCode)")
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}.resume()
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private func testModelsEndpoint(completion: @escaping (Bool, String) -> Void) {
|
|
883
|
+
guard let url = URL(string: "\(Config.mageagentURL)/v1/models") else {
|
|
884
|
+
completion(false, "Invalid URL")
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
var request = URLRequest(url: url)
|
|
889
|
+
request.timeoutInterval = 5.0
|
|
890
|
+
|
|
891
|
+
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
892
|
+
DispatchQueue.main.async {
|
|
893
|
+
if let error = error {
|
|
894
|
+
completion(false, error.localizedDescription)
|
|
895
|
+
return
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
899
|
+
completion(false, "No HTTP response")
|
|
900
|
+
return
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if httpResponse.statusCode == 200 {
|
|
904
|
+
if let data = data,
|
|
905
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
906
|
+
let models = json["data"] as? [[String: Any]] {
|
|
907
|
+
completion(true, "\(models.count) models available")
|
|
908
|
+
} else {
|
|
909
|
+
completion(true, "")
|
|
910
|
+
}
|
|
911
|
+
} else {
|
|
912
|
+
completion(false, "HTTP \(httpResponse.statusCode)")
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}.resume()
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
private func testModel(_ modelId: String, completion: @escaping (Bool, String) -> Void) {
|
|
919
|
+
guard let url = URL(string: "\(Config.mageagentURL)/v1/chat/completions") else {
|
|
920
|
+
completion(false, "Invalid URL")
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
var request = URLRequest(url: url)
|
|
925
|
+
request.httpMethod = "POST"
|
|
926
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
927
|
+
request.timeoutInterval = 120 // 2 minutes for model loading
|
|
928
|
+
|
|
929
|
+
let body: [String: Any] = [
|
|
930
|
+
"model": modelId,
|
|
931
|
+
"messages": [["role": "user", "content": "Say 'test passed' in exactly 2 words."]],
|
|
932
|
+
"max_tokens": 10,
|
|
933
|
+
"temperature": 0.1
|
|
934
|
+
]
|
|
935
|
+
|
|
936
|
+
do {
|
|
937
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
938
|
+
} catch {
|
|
939
|
+
completion(false, "Failed to create request")
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
let startTime = Date()
|
|
944
|
+
|
|
945
|
+
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
946
|
+
DispatchQueue.main.async {
|
|
947
|
+
let elapsed = Date().timeIntervalSince(startTime)
|
|
948
|
+
let timeStr = String(format: "%.1fs", elapsed)
|
|
949
|
+
|
|
950
|
+
if let error = error {
|
|
951
|
+
completion(false, "\(error.localizedDescription) (\(timeStr))")
|
|
952
|
+
return
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
956
|
+
completion(false, "No HTTP response (\(timeStr))")
|
|
957
|
+
return
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if httpResponse.statusCode == 200 {
|
|
961
|
+
if let data = data,
|
|
962
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
963
|
+
let choices = json["choices"] as? [[String: Any]],
|
|
964
|
+
let firstChoice = choices.first,
|
|
965
|
+
let message = firstChoice["message"] as? [String: Any],
|
|
966
|
+
let content = message["content"] as? String {
|
|
967
|
+
let preview = content.prefix(30).replacingOccurrences(of: "\n", with: " ")
|
|
968
|
+
completion(true, "\"\(preview)\" (\(timeStr))")
|
|
969
|
+
} else {
|
|
970
|
+
completion(true, "Response received (\(timeStr))")
|
|
971
|
+
}
|
|
972
|
+
} else {
|
|
973
|
+
var errorMsg = "HTTP \(httpResponse.statusCode)"
|
|
974
|
+
if let data = data,
|
|
975
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
976
|
+
let detail = json["detail"] as? String {
|
|
977
|
+
errorMsg += ": \(detail)"
|
|
978
|
+
}
|
|
979
|
+
completion(false, "\(errorMsg) (\(timeStr))")
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}.resume()
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
@objc func showSettingsAction(_ sender: NSMenuItem) {
|
|
986
|
+
debugLog("Settings action triggered")
|
|
987
|
+
|
|
988
|
+
// Bring app to front for modal dialog
|
|
989
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
990
|
+
|
|
991
|
+
let alert = NSAlert()
|
|
992
|
+
alert.messageText = "MageAgent Settings"
|
|
993
|
+
alert.informativeText = """
|
|
994
|
+
MageAgent Server Configuration
|
|
995
|
+
|
|
996
|
+
Port: 3457
|
|
997
|
+
API: \(Config.mageagentURL)
|
|
998
|
+
Script: \(Config.mageagentScript)
|
|
999
|
+
Logs: \(Config.logFile)
|
|
1000
|
+
|
|
1001
|
+
Loaded Models:
|
|
1002
|
+
• Primary: Qwen-72B Q8 (reasoning)
|
|
1003
|
+
• Tools: Hermes-3 8B Q8 (tool calling)
|
|
1004
|
+
• Validator: Qwen-Coder 7B (fast)
|
|
1005
|
+
• Competitor: Qwen-Coder 32B (coding)
|
|
1006
|
+
|
|
1007
|
+
Available Patterns:
|
|
1008
|
+
• auto: Intelligent task routing
|
|
1009
|
+
• execute: Real tool execution (ReAct)
|
|
1010
|
+
• hybrid: Reasoning + tool extraction
|
|
1011
|
+
• validated: Generate + validate
|
|
1012
|
+
• compete: Multi-model with judge
|
|
1013
|
+
|
|
1014
|
+
Status: \(isServerRunning ? "Running" : "Stopped")
|
|
1015
|
+
"""
|
|
1016
|
+
alert.alertStyle = .informational
|
|
1017
|
+
alert.addButton(withTitle: "Close")
|
|
1018
|
+
alert.addButton(withTitle: "Open Logs Folder")
|
|
1019
|
+
|
|
1020
|
+
let response = alert.runModal()
|
|
1021
|
+
if response == .alertSecondButtonReturn {
|
|
1022
|
+
let logsFolder = (Config.logFile as NSString).deletingLastPathComponent
|
|
1023
|
+
NSWorkspace.shared.open(URL(fileURLWithPath: logsFolder))
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// MARK: - Server Status Checking
|
|
1028
|
+
|
|
1029
|
+
private func startStatusTimer() {
|
|
1030
|
+
statusTimer?.invalidate()
|
|
1031
|
+
statusTimer = Timer.scheduledTimer(withTimeInterval: Config.statusCheckInterval, repeats: true) { [weak self] _ in
|
|
1032
|
+
self?.checkServerStatus()
|
|
1033
|
+
}
|
|
1034
|
+
// Add to common run loop mode to ensure it fires even during menu tracking
|
|
1035
|
+
RunLoop.current.add(statusTimer!, forMode: .common)
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private func checkServerStatus() {
|
|
1039
|
+
guard let url = URL(string: "\(Config.mageagentURL)/health") else {
|
|
1040
|
+
debugLog("ERROR: Invalid health check URL")
|
|
1041
|
+
return
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
var request = URLRequest(url: url)
|
|
1045
|
+
request.timeoutInterval = Config.requestTimeout
|
|
1046
|
+
request.httpMethod = "GET"
|
|
1047
|
+
|
|
1048
|
+
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
|
1049
|
+
DispatchQueue.main.async {
|
|
1050
|
+
guard let self = self else { return }
|
|
1051
|
+
|
|
1052
|
+
if let error = error {
|
|
1053
|
+
self.debugLog("Health check failed: \(error.localizedDescription)")
|
|
1054
|
+
self.updateServerStatus(running: false, version: nil, models: [])
|
|
1055
|
+
return
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
guard let httpResponse = response as? HTTPURLResponse,
|
|
1059
|
+
httpResponse.statusCode == 200,
|
|
1060
|
+
let data = data else {
|
|
1061
|
+
self.updateServerStatus(running: false, version: nil, models: [])
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
do {
|
|
1066
|
+
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
1067
|
+
let version = json["version"] as? String
|
|
1068
|
+
let models = json["loaded_models"] as? [String] ?? []
|
|
1069
|
+
self.updateServerStatus(running: true, version: version, models: models)
|
|
1070
|
+
}
|
|
1071
|
+
} catch {
|
|
1072
|
+
self.debugLog("Failed to parse health response: \(error)")
|
|
1073
|
+
self.updateServerStatus(running: false, version: nil, models: [])
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}.resume()
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
private func updateServerStatus(running: Bool, version: String?, models: [String]) {
|
|
1080
|
+
isServerRunning = running
|
|
1081
|
+
|
|
1082
|
+
// Update status menu item
|
|
1083
|
+
if let statusItem = menu.item(withTag: MenuItemTag.status.rawValue) {
|
|
1084
|
+
if running {
|
|
1085
|
+
let versionStr = version ?? "?"
|
|
1086
|
+
statusItem.title = "Status: Running (v\(versionStr))"
|
|
1087
|
+
} else {
|
|
1088
|
+
statusItem.title = "Status: Stopped"
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Update loaded models set from server response
|
|
1093
|
+
loadedModels = Set(models.map { modelName in
|
|
1094
|
+
// Convert model names to our modelId format
|
|
1095
|
+
if modelName.contains("72B") || modelName.contains("primary") {
|
|
1096
|
+
return "mageagent:primary"
|
|
1097
|
+
} else if modelName.contains("Hermes") || modelName.contains("tools") {
|
|
1098
|
+
return "mageagent:tools"
|
|
1099
|
+
} else if modelName.contains("7B") || modelName.contains("validator") {
|
|
1100
|
+
return "mageagent:validator"
|
|
1101
|
+
} else if modelName.contains("32B") || modelName.contains("competitor") {
|
|
1102
|
+
return "mageagent:competitor"
|
|
1103
|
+
}
|
|
1104
|
+
return modelName
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
// Update checkmarks on model menu items based on loaded status
|
|
1108
|
+
if let modelsItem = menu.item(withTag: MenuItemTag.models.rawValue),
|
|
1109
|
+
let submenu = modelsItem.submenu {
|
|
1110
|
+
for item in submenu.items {
|
|
1111
|
+
if let modelId = item.representedObject as? String {
|
|
1112
|
+
// Check if this model is loaded
|
|
1113
|
+
item.state = loadedModels.contains(modelId) ? .on : .off
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// MARK: - Script Execution
|
|
1120
|
+
|
|
1121
|
+
private func runServerScript(_ command: String, completion: @escaping (Bool, String) -> Void) {
|
|
1122
|
+
debugLog("Executing server script: \(command)")
|
|
1123
|
+
|
|
1124
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
1125
|
+
guard let self = self else {
|
|
1126
|
+
DispatchQueue.main.async { completion(false, "AppDelegate deallocated") }
|
|
1127
|
+
return
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
let task = Process()
|
|
1131
|
+
task.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
1132
|
+
task.arguments = ["-l", "-c", "\(Config.mageagentScript) \(command)"]
|
|
1133
|
+
|
|
1134
|
+
// Configure environment
|
|
1135
|
+
var environment = ProcessInfo.processInfo.environment
|
|
1136
|
+
environment["HOME"] = NSHomeDirectory()
|
|
1137
|
+
environment["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
1138
|
+
// Ensure Python/UV paths are available
|
|
1139
|
+
if let pyenvRoot = environment["PYENV_ROOT"] {
|
|
1140
|
+
environment["PATH"] = "\(pyenvRoot)/shims:\(environment["PATH"] ?? "")"
|
|
1141
|
+
}
|
|
1142
|
+
task.environment = environment
|
|
1143
|
+
|
|
1144
|
+
let outputPipe = Pipe()
|
|
1145
|
+
let errorPipe = Pipe()
|
|
1146
|
+
task.standardOutput = outputPipe
|
|
1147
|
+
task.standardError = errorPipe
|
|
1148
|
+
|
|
1149
|
+
do {
|
|
1150
|
+
try task.run()
|
|
1151
|
+
task.waitUntilExit()
|
|
1152
|
+
|
|
1153
|
+
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
|
1154
|
+
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
|
1155
|
+
let output = String(data: outputData, encoding: .utf8) ?? ""
|
|
1156
|
+
let errorOutput = String(data: errorData, encoding: .utf8) ?? ""
|
|
1157
|
+
|
|
1158
|
+
let combinedOutput = output + (errorOutput.isEmpty ? "" : "\nErrors: \(errorOutput)")
|
|
1159
|
+
|
|
1160
|
+
self.debugLog("Script '\(command)' completed with status: \(task.terminationStatus)")
|
|
1161
|
+
self.debugLog("Output: \(combinedOutput.prefix(500))")
|
|
1162
|
+
|
|
1163
|
+
DispatchQueue.main.async {
|
|
1164
|
+
completion(task.terminationStatus == 0, combinedOutput)
|
|
1165
|
+
}
|
|
1166
|
+
} catch {
|
|
1167
|
+
self.debugLog("Script execution failed: \(error.localizedDescription)")
|
|
1168
|
+
DispatchQueue.main.async {
|
|
1169
|
+
completion(false, error.localizedDescription)
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// MARK: - Notifications
|
|
1176
|
+
|
|
1177
|
+
/// Track if notifications are authorized
|
|
1178
|
+
private var notificationsAuthorized: Bool = false
|
|
1179
|
+
|
|
1180
|
+
private func requestNotificationPermission() {
|
|
1181
|
+
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in
|
|
1182
|
+
DispatchQueue.main.async {
|
|
1183
|
+
self?.notificationsAuthorized = granted
|
|
1184
|
+
if let error = error {
|
|
1185
|
+
self?.debugLog("Notification permission error: \(error.localizedDescription)")
|
|
1186
|
+
} else {
|
|
1187
|
+
self?.debugLog("Notification permission granted: \(granted)")
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Also check current authorization status
|
|
1193
|
+
UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
|
|
1194
|
+
DispatchQueue.main.async {
|
|
1195
|
+
self?.notificationsAuthorized = settings.authorizationStatus == .authorized
|
|
1196
|
+
self?.debugLog("Notification settings: \(settings.authorizationStatus.rawValue)")
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private func sendNotification(title: String, body: String) {
|
|
1202
|
+
debugLog("Sending notification: \(title) - \(body)")
|
|
1203
|
+
|
|
1204
|
+
// Try modern UNUserNotificationCenter first
|
|
1205
|
+
let content = UNMutableNotificationContent()
|
|
1206
|
+
content.title = title
|
|
1207
|
+
content.body = body
|
|
1208
|
+
content.sound = .default
|
|
1209
|
+
|
|
1210
|
+
let request = UNNotificationRequest(
|
|
1211
|
+
identifier: UUID().uuidString,
|
|
1212
|
+
content: content,
|
|
1213
|
+
trigger: nil // Deliver immediately
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
UNUserNotificationCenter.current().add(request) { [weak self] error in
|
|
1217
|
+
if let error = error {
|
|
1218
|
+
self?.debugLog("UNUserNotification failed: \(error.localizedDescription)")
|
|
1219
|
+
// Fallback to visual toast
|
|
1220
|
+
DispatchQueue.main.async {
|
|
1221
|
+
self?.showToastAlert(title: title, body: body)
|
|
1222
|
+
}
|
|
1223
|
+
} else {
|
|
1224
|
+
self?.debugLog("Notification sent successfully")
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/// Fallback toast using a floating panel
|
|
1230
|
+
private func showToastAlert(title: String, body: String) {
|
|
1231
|
+
// Create a small floating window as a toast
|
|
1232
|
+
let toastWindow = NSPanel(
|
|
1233
|
+
contentRect: NSRect(x: 0, y: 0, width: 300, height: 80),
|
|
1234
|
+
styleMask: [.titled, .nonactivatingPanel, .fullSizeContentView],
|
|
1235
|
+
backing: .buffered,
|
|
1236
|
+
defer: false
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
toastWindow.isFloatingPanel = true
|
|
1240
|
+
toastWindow.level = .floating
|
|
1241
|
+
toastWindow.titleVisibility = .hidden
|
|
1242
|
+
toastWindow.titlebarAppearsTransparent = true
|
|
1243
|
+
toastWindow.isMovableByWindowBackground = true
|
|
1244
|
+
toastWindow.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.95)
|
|
1245
|
+
|
|
1246
|
+
// Create content view
|
|
1247
|
+
let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 80))
|
|
1248
|
+
|
|
1249
|
+
// Icon
|
|
1250
|
+
let iconView = NSImageView(frame: NSRect(x: 15, y: 22, width: 36, height: 36))
|
|
1251
|
+
if let iconImage = NSImage(contentsOfFile: Config.iconPath) {
|
|
1252
|
+
iconImage.size = NSSize(width: 36, height: 36)
|
|
1253
|
+
iconView.image = iconImage
|
|
1254
|
+
} else if let symbolImage = NSImage(systemSymbolName: "brain.head.profile", accessibilityDescription: "MageAgent") {
|
|
1255
|
+
iconView.image = symbolImage
|
|
1256
|
+
}
|
|
1257
|
+
contentView.addSubview(iconView)
|
|
1258
|
+
|
|
1259
|
+
// Title
|
|
1260
|
+
let titleLabel = NSTextField(labelWithString: title)
|
|
1261
|
+
titleLabel.frame = NSRect(x: 60, y: 48, width: 225, height: 20)
|
|
1262
|
+
titleLabel.font = NSFont.boldSystemFont(ofSize: 13)
|
|
1263
|
+
titleLabel.textColor = NSColor.labelColor
|
|
1264
|
+
contentView.addSubview(titleLabel)
|
|
1265
|
+
|
|
1266
|
+
// Body
|
|
1267
|
+
let bodyLabel = NSTextField(labelWithString: body)
|
|
1268
|
+
bodyLabel.frame = NSRect(x: 60, y: 12, width: 225, height: 32)
|
|
1269
|
+
bodyLabel.font = NSFont.systemFont(ofSize: 11)
|
|
1270
|
+
bodyLabel.textColor = NSColor.secondaryLabelColor
|
|
1271
|
+
bodyLabel.lineBreakMode = .byTruncatingTail
|
|
1272
|
+
bodyLabel.maximumNumberOfLines = 2
|
|
1273
|
+
contentView.addSubview(bodyLabel)
|
|
1274
|
+
|
|
1275
|
+
toastWindow.contentView = contentView
|
|
1276
|
+
|
|
1277
|
+
// Position in top-right corner
|
|
1278
|
+
if let screen = NSScreen.main {
|
|
1279
|
+
let screenFrame = screen.visibleFrame
|
|
1280
|
+
let windowFrame = toastWindow.frame
|
|
1281
|
+
let x = screenFrame.maxX - windowFrame.width - 20
|
|
1282
|
+
let y = screenFrame.maxY - windowFrame.height - 10
|
|
1283
|
+
toastWindow.setFrameOrigin(NSPoint(x: x, y: y))
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Show with animation
|
|
1287
|
+
toastWindow.alphaValue = 0
|
|
1288
|
+
toastWindow.makeKeyAndOrderFront(nil)
|
|
1289
|
+
toastWindow.orderFrontRegardless()
|
|
1290
|
+
|
|
1291
|
+
NSAnimationContext.runAnimationGroup { context in
|
|
1292
|
+
context.duration = 0.3
|
|
1293
|
+
toastWindow.animator().alphaValue = 1.0
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Auto-dismiss after 3 seconds
|
|
1297
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
|
1298
|
+
NSAnimationContext.runAnimationGroup({ context in
|
|
1299
|
+
context.duration = 0.3
|
|
1300
|
+
toastWindow.animator().alphaValue = 0
|
|
1301
|
+
}) {
|
|
1302
|
+
toastWindow.close()
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// MARK: - Debug Logging
|
|
1308
|
+
|
|
1309
|
+
private func debugLog(_ message: String) {
|
|
1310
|
+
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
1311
|
+
let logMessage = "[\(timestamp)] \(message)"
|
|
1312
|
+
|
|
1313
|
+
// Print to console
|
|
1314
|
+
print(logMessage)
|
|
1315
|
+
|
|
1316
|
+
// Write to debug log file
|
|
1317
|
+
let logEntry = logMessage + "\n"
|
|
1318
|
+
guard let data = logEntry.data(using: .utf8) else { return }
|
|
1319
|
+
|
|
1320
|
+
let logDir = (Config.debugLogFile as NSString).deletingLastPathComponent
|
|
1321
|
+
|
|
1322
|
+
// Ensure log directory exists
|
|
1323
|
+
if !FileManager.default.fileExists(atPath: logDir) {
|
|
1324
|
+
try? FileManager.default.createDirectory(atPath: logDir, withIntermediateDirectories: true)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
if FileManager.default.fileExists(atPath: Config.debugLogFile) {
|
|
1328
|
+
if let fileHandle = FileHandle(forWritingAtPath: Config.debugLogFile) {
|
|
1329
|
+
defer { fileHandle.closeFile() }
|
|
1330
|
+
fileHandle.seekToEndOfFile()
|
|
1331
|
+
fileHandle.write(data)
|
|
1332
|
+
}
|
|
1333
|
+
} else {
|
|
1334
|
+
try? data.write(to: URL(fileURLWithPath: Config.debugLogFile))
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|