feat: enhance AI Workspace Menu Bar App with packaging scripts, login management, and service status improvements
This commit is contained in:
1
apps/mac/AIWorkspace/.gitignore
vendored
1
apps/mac/AIWorkspace/.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
.build/
|
.build/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
|
dist/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
*.xcworkspace
|
*.xcworkspace
|
||||||
|
|||||||
@@ -16,6 +16,32 @@ and sends lifecycle actions through `scripts/aiw/services.py`.
|
|||||||
swift build --package-path apps/mac/AIWorkspace
|
swift build --package-path apps/mac/AIWorkspace
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Package as `.app`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/package-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Install to `~/Applications/AIWorkspace.app`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/package-app.sh --install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Start at login
|
||||||
|
|
||||||
|
After installing the app bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To remove the login item:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Run during development
|
## Run during development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -32,6 +58,7 @@ swift run --package-path apps/mac/AIWorkspace AIWorkspace
|
|||||||
- Run Doctor
|
- Run Doctor
|
||||||
- Copy Doctor JSON
|
- Copy Doctor JSON
|
||||||
- Copy Photo Inbox URL
|
- Copy Photo Inbox URL
|
||||||
|
- Copy recent logs
|
||||||
- Open MCP Health
|
- Open MCP Health
|
||||||
- Open logs folder
|
- Open logs folder
|
||||||
- Open project knowledge
|
- Open project knowledge
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ struct AIWorkspaceApp: App {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("AI Workspace", systemImage: model.menuBarSymbol)
|
Label("AI Workspace", systemImage: model.menuBarSymbol)
|
||||||
}
|
}
|
||||||
.menuBarExtraStyle(.menu)
|
.menuBarExtraStyle(.window)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ struct AIWorkspaceApp: App {
|
|||||||
final class ServiceStatusModel: ObservableObject {
|
final class ServiceStatusModel: ObservableObject {
|
||||||
@Published private(set) var report: StatusReport?
|
@Published private(set) var report: StatusReport?
|
||||||
@Published private(set) var lastError: String?
|
@Published private(set) var lastError: String?
|
||||||
|
@Published private(set) var lanIP: String?
|
||||||
@Published private(set) var isRefreshing = false
|
@Published private(set) var isRefreshing = false
|
||||||
|
|
||||||
let profile: String
|
let profile: String
|
||||||
@@ -51,6 +52,7 @@ final class ServiceStatusModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
|
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
|
||||||
report = try JSONDecoder().decode(StatusReport.self, from: data)
|
report = try JSONDecoder().decode(StatusReport.self, from: data)
|
||||||
|
lanIP = await NetworkInfo.primaryLANIP()
|
||||||
lastError = nil
|
lastError = nil
|
||||||
} catch {
|
} catch {
|
||||||
lastError = String(describing: error)
|
lastError = String(describing: error)
|
||||||
@@ -85,7 +87,25 @@ final class ServiceStatusModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func copyPhotoInboxURL() {
|
func copyPhotoInboxURL() {
|
||||||
copyToPasteboard("http://127.0.0.1:8787/upload")
|
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() {
|
func openMCPHealth() {
|
||||||
@@ -127,18 +147,32 @@ struct ServiceMenuView: View {
|
|||||||
@ObservedObject var model: ServiceStatusModel
|
@ObservedObject var model: ServiceStatusModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Text("AI Workspace")
|
HStack(alignment: .firstTextBaseline) {
|
||||||
.font(.headline)
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text("Profile: \(model.profile)")
|
Text("AI Workspace")
|
||||||
.font(.caption)
|
.font(.title3.bold())
|
||||||
.foregroundStyle(.secondary)
|
Text("Profile: \(model.profile)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
Task { await model.refresh() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Refresh")
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
if let report = model.report {
|
if let report = model.report {
|
||||||
ForEach(report.services) { service in
|
VStack(spacing: 8) {
|
||||||
ServiceRow(service: service)
|
ForEach(report.services) { service in
|
||||||
|
ServiceRow(service: service)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if let error = model.lastError {
|
} else if let error = model.lastError {
|
||||||
Label("Status unavailable", systemImage: "exclamationmark.triangle")
|
Label("Status unavailable", systemImage: "exclamationmark.triangle")
|
||||||
@@ -150,61 +184,74 @@ struct ServiceMenuView: View {
|
|||||||
Label("Loading status...", systemImage: "hourglass")
|
Label("Loading status...", systemImage: "hourglass")
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
if let lanIP = model.lanIP {
|
||||||
|
HStack(spacing: 8) {
|
||||||
Button("Refresh") {
|
Image(systemName: "wifi")
|
||||||
Task { await model.refresh() }
|
.foregroundStyle(.secondary)
|
||||||
}
|
Text("Photo Inbox LAN")
|
||||||
.keyboardShortcut("r")
|
Spacer()
|
||||||
|
Text("http://\(lanIP):8787/upload")
|
||||||
Button("Start Fidelity") {
|
.font(.caption.monospaced())
|
||||||
model.startProfile()
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
.textSelection(.enabled)
|
||||||
Button("Stop Fidelity") {
|
|
||||||
model.stopProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Restart Context MCP") {
|
|
||||||
model.restartMCP()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Open Mattermost") {
|
|
||||||
model.openMattermost()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Run Doctor") {
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||||
model.runDoctor()
|
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)
|
||||||
Button("Copy Doctor JSON") {
|
ActionButton(title: "Mattermost", systemImage: "message", action: model.openMattermost)
|
||||||
model.copyDoctorJSON()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Copy Photo Inbox URL") {
|
|
||||||
model.copyPhotoInboxURL()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Open MCP Health") {
|
|
||||||
model.openMCPHealth()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Open Logs Folder") {
|
|
||||||
model.openLogsFolder()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Open Project Knowledge") {
|
|
||||||
model.openProjectKnowledge()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
Button("Quit") {
|
|
||||||
NSApplication.shared.terminate(nil)
|
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")
|
||||||
}
|
}
|
||||||
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,17 +263,20 @@ struct ServiceRow: View {
|
|||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
.frame(width: 16, alignment: .center)
|
.frame(width: 16, alignment: .center)
|
||||||
Text(service.displayName)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.lineLimit(1)
|
Text(service.displayName)
|
||||||
.frame(width: 170, alignment: .leading)
|
.font(.body.weight(.medium))
|
||||||
Spacer()
|
Text(service.health.detail)
|
||||||
Text(service.compactStatus)
|
.font(.caption2)
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.lineLimit(1)
|
||||||
.monospacedDigit()
|
}
|
||||||
.frame(width: 86, alignment: .trailing)
|
Spacer(minLength: 12)
|
||||||
|
StatusBadge(text: service.compactStatus, color: color)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 300, alignment: .leading)
|
.padding(.vertical, 7)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.help(service.health.detail)
|
.help(service.health.detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +301,20 @@ struct ServiceRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
enum ServiceManager {
|
||||||
static func run(_ arguments: [String]) async throws -> Data {
|
static func run(_ arguments: [String]) async throws -> Data {
|
||||||
try await Task.detached(priority: .userInitiated) {
|
try await Task.detached(priority: .userInitiated) {
|
||||||
@@ -278,6 +342,33 @@ enum ServiceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
enum ServiceManagerError: LocalizedError {
|
||||||
case commandFailed(String)
|
case commandFailed(String)
|
||||||
|
|
||||||
|
|||||||
40
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
Normal file
40
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_PATH="${APP_PATH:-$HOME/Applications/AIWorkspace.app}"
|
||||||
|
PLIST_PATH="$HOME/Library/LaunchAgents/com.aiworkspace.menu.plist"
|
||||||
|
|
||||||
|
if [[ ! -d "$APP_PATH" ]]; then
|
||||||
|
echo "App not found: $APP_PATH" >&2
|
||||||
|
echo "Run apps/mac/AIWorkspace/scripts/package-app.sh --install first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME/Library/LaunchAgents"
|
||||||
|
|
||||||
|
cat > "$PLIST_PATH" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.aiworkspace.menu</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/open</string>
|
||||||
|
<string>-a</string>
|
||||||
|
<string>$APP_PATH</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>$HOME/Library/Logs/AIWorkspace-menu.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>$HOME/Library/Logs/AIWorkspace-menu.err.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
launchctl unload "$PLIST_PATH" >/dev/null 2>&1 || true
|
||||||
|
launchctl load "$PLIST_PATH"
|
||||||
|
echo "Installed and loaded LaunchAgent: $PLIST_PATH"
|
||||||
64
apps/mac/AIWorkspace/scripts/package-app.sh
Normal file
64
apps/mac/AIWorkspace/scripts/package-app.sh
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
WORKSPACE_ROOT="$(cd "$APP_ROOT/../../.." && pwd)"
|
||||||
|
|
||||||
|
CONFIGURATION="${CONFIGURATION:-release}"
|
||||||
|
APP_NAME="AIWorkspace"
|
||||||
|
BUILD_DIR="$APP_ROOT/.build/$CONFIGURATION"
|
||||||
|
OUTPUT_DIR="$APP_ROOT/dist"
|
||||||
|
APP_BUNDLE="$OUTPUT_DIR/$APP_NAME.app"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-$HOME/Applications}"
|
||||||
|
|
||||||
|
INSTALL=0
|
||||||
|
if [[ "${1:-}" == "--install" ]]; then
|
||||||
|
INSTALL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
swift build --package-path "$APP_ROOT" -c "$CONFIGURATION"
|
||||||
|
|
||||||
|
rm -rf "$APP_BUNDLE"
|
||||||
|
mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources"
|
||||||
|
cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
|
||||||
|
|
||||||
|
cat > "$APP_BUNDLE/Contents/Info.plist" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$APP_NAME</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.aiworkspace.menu</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>AI Workspace</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Local AI Workspace utility.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
echo "Built $APP_BUNDLE"
|
||||||
|
|
||||||
|
if [[ "$INSTALL" == "1" ]]; then
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
rm -rf "$INSTALL_DIR/$APP_NAME.app"
|
||||||
|
cp -R "$APP_BUNDLE" "$INSTALL_DIR/$APP_NAME.app"
|
||||||
|
echo "Installed $INSTALL_DIR/$APP_NAME.app"
|
||||||
|
fi
|
||||||
7
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
Normal file
7
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PLIST_PATH="$HOME/Library/LaunchAgents/com.aiworkspace.menu.plist"
|
||||||
|
launchctl unload "$PLIST_PATH" >/dev/null 2>&1 || true
|
||||||
|
rm -f "$PLIST_PATH"
|
||||||
|
echo "Removed LaunchAgent: $PLIST_PATH"
|
||||||
@@ -12,6 +12,7 @@ The app should not reimplement service logic. It should call the service manager
|
|||||||
- Local-only, no cloud dependency.
|
- Local-only, no cloud dependency.
|
||||||
- Start at login optional through `LaunchAgent` later.
|
- Start at login optional through `LaunchAgent` later.
|
||||||
- Read-only status by default; explicit user actions for start/stop/restart.
|
- Read-only status by default; explicit user actions for start/stop/restart.
|
||||||
|
- The UI should not own MCP profile selection. AI clients pass profiles to MCP tools/resources; the menu bar app is only a local operations surface.
|
||||||
|
|
||||||
## Initial UI
|
## Initial UI
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ AI Workspace ▾
|
|||||||
Open Mattermost
|
Open Mattermost
|
||||||
Open Photo Inbox Folder
|
Open Photo Inbox Folder
|
||||||
Copy Photo Inbox Upload URL
|
Copy Photo Inbox Upload URL
|
||||||
|
Copy Recent Logs
|
||||||
Open Project Knowledge
|
Open Project Knowledge
|
||||||
Open Logs
|
Open Logs
|
||||||
|
|
||||||
@@ -75,3 +77,5 @@ Use `status --json` for frequent UI refreshes and `doctor --json` for explicit d
|
|||||||
2. Add profile selector and service action buttons.
|
2. Add profile selector and service action buttons.
|
||||||
3. Add LaunchAgent support for start at login.
|
3. Add LaunchAgent support for start at login.
|
||||||
4. Replace shell parsing with a daemon API if daily use proves stable.
|
4. Replace shell parsing with a daemon API if daily use proves stable.
|
||||||
|
|
||||||
|
See `multi-profile-runtime-model.md` for how this should evolve when multiple profiles run in parallel.
|
||||||
|
|||||||
49
core/services/multi-profile-runtime-model.md
Normal file
49
core/services/multi-profile-runtime-model.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Multi-Profile Runtime Model
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
Profiles are selected by clients and services, not by the menu bar UI.
|
||||||
|
|
||||||
|
The menu bar app is a local operations surface for the current operator machine. It can monitor the local services that are enabled now, but it should not become the source of truth for which project an AI client is allowed to query.
|
||||||
|
|
||||||
|
## Desired Model
|
||||||
|
|
||||||
|
- MCP clients choose the profile at call time, for example `{"profile":"fidelity"}`.
|
||||||
|
- Multiple profiles may run in parallel when their service ports and inbox paths do not conflict.
|
||||||
|
- Capture services can be profile-specific or shared:
|
||||||
|
- shared service: one Mattermost mirror with profile-scoped query filters;
|
||||||
|
- profile-specific service: separate mirror/output path/port per profile.
|
||||||
|
- The MCP query layer should remain profile-aware and read from profile manifests/config.
|
||||||
|
- The menu bar app should show local runtime health, not force a single global profile selection.
|
||||||
|
|
||||||
|
## Current Fidelity Setup
|
||||||
|
|
||||||
|
The first menu bar version targets the Fidelity service set because that is the active local workflow. This does not prevent MCP clients from querying other profiles when those profiles have canonical memory or context-source config.
|
||||||
|
|
||||||
|
## Parallel Profile Requirements
|
||||||
|
|
||||||
|
Before running another profile in parallel, define unique values for any conflicting service:
|
||||||
|
|
||||||
|
- MCP HTTP port if using separate MCP instances.
|
||||||
|
- Mattermost proxy listen port if using separate proxy instances.
|
||||||
|
- Photo Inbox port if using separate upload receivers.
|
||||||
|
- Mirror/inbox output directory.
|
||||||
|
- Profile-specific context channels.
|
||||||
|
|
||||||
|
Example future split:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fidelity:
|
||||||
|
aiw-context-mcp: 127.0.0.1:8765
|
||||||
|
mattermost-proxy: 127.0.0.1:8080
|
||||||
|
photo-inbox: 0.0.0.0:8787
|
||||||
|
|
||||||
|
it-support:
|
||||||
|
aiw-context-mcp: same shared MCP, profile argument selects context
|
||||||
|
mattermost-proxy: 127.0.0.1:8081 if a separate capture session is needed
|
||||||
|
photo-inbox: 0.0.0.0:8788 if a separate receiver is needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Menu Bar Direction
|
||||||
|
|
||||||
|
The menu bar app should evolve toward showing service groups or all running profiles, but not a manual profile selector for MCP query behavior. Profile selection belongs in the MCP tool/resource arguments and client prompts.
|
||||||
Reference in New Issue
Block a user