- Restructure project into three targets: - AppleIntelligenceCore: Shared gRPC service code - AppleIntelligenceServer: CLI server - AppleIntelligenceApp: Menu bar app - Menu bar app features: - Toggle server on/off from menu bar - Chat window with streaming AI responses - Settings: host, port, API key, auto-start, launch at login - Proper window focus handling for menu bar apps - Add build scripts for distribution: - build-app.sh: Creates signed .app bundle - create-dmg.sh: Creates distributable DMG 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
174 lines
5.8 KiB
Swift
174 lines
5.8 KiB
Swift
import SwiftUI
|
|
|
|
struct ChatView: View {
|
|
@Bindable var viewModel: ChatViewModel
|
|
@FocusState private var isInputFocused: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Messages list
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(spacing: 12) {
|
|
ForEach(viewModel.messages) { message in
|
|
MessageBubble(message: message)
|
|
.id(message.id)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.onChange(of: viewModel.messages.count) { _, _ in
|
|
if let lastMessage = viewModel.messages.last {
|
|
withAnimation {
|
|
proxy.scrollTo(lastMessage.id, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: viewModel.messages.last?.content) { _, _ in
|
|
if let lastMessage = viewModel.messages.last {
|
|
withAnimation {
|
|
proxy.scrollTo(lastMessage.id, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Error message
|
|
if let error = viewModel.errorMessage {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.yellow)
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("Dismiss") {
|
|
viewModel.errorMessage = nil
|
|
}
|
|
.buttonStyle(.plain)
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
.background(.red.opacity(0.1))
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Input area
|
|
HStack(spacing: 12) {
|
|
TextField("Message...", text: $viewModel.inputText, axis: .vertical)
|
|
.textFieldStyle(.plain)
|
|
.lineLimit(1...5)
|
|
.focused($isInputFocused)
|
|
.onSubmit {
|
|
if !viewModel.inputText.isEmpty {
|
|
viewModel.sendMessage()
|
|
}
|
|
}
|
|
|
|
if viewModel.isLoading {
|
|
Button {
|
|
viewModel.stopGeneration()
|
|
} label: {
|
|
Image(systemName: "stop.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.red)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
Button {
|
|
viewModel.sendMessage()
|
|
} label: {
|
|
Image(systemName: "arrow.up.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(viewModel.inputText.isEmpty ? .gray : .accentColor)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(viewModel.inputText.isEmpty)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(minWidth: 400, minHeight: 500)
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
viewModel.clearChat()
|
|
} label: {
|
|
Image(systemName: "trash")
|
|
}
|
|
.help("Clear chat")
|
|
.disabled(viewModel.messages.isEmpty)
|
|
}
|
|
}
|
|
.task {
|
|
await viewModel.initialize()
|
|
}
|
|
.onAppear {
|
|
// Force the app to become active and accept keyboard input
|
|
NSApp.setActivationPolicy(.regular)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
// Make sure the window is key
|
|
if let window = NSApp.windows.first(where: { $0.title == "Chat" }) {
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
isInputFocused = true
|
|
}
|
|
}
|
|
.onDisappear {
|
|
// Return to accessory mode when chat is closed
|
|
if NSApp.windows.filter({ $0.isVisible && $0.title != "" }).isEmpty {
|
|
NSApp.setActivationPolicy(.accessory)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MessageBubble: View {
|
|
let message: ChatMessage
|
|
|
|
var body: some View {
|
|
HStack {
|
|
if message.role == .user {
|
|
Spacer(minLength: 60)
|
|
}
|
|
|
|
VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) {
|
|
Text(message.content)
|
|
.textSelection(.enabled)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(bubbleColor)
|
|
.foregroundStyle(message.role == .user ? .white : .primary)
|
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
|
|
if message.isStreaming {
|
|
HStack(spacing: 4) {
|
|
ProgressView()
|
|
.scaleEffect(0.6)
|
|
Text("Generating...")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
if message.role == .assistant {
|
|
Spacer(minLength: 60)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var bubbleColor: Color {
|
|
switch message.role {
|
|
case .user:
|
|
return .accentColor
|
|
case .assistant:
|
|
return Color(nsColor: .controlBackgroundColor)
|
|
}
|
|
}
|
|
}
|