Add macOS menu bar app with chat and settings
- 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>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// Server configuration loaded from environment variables
|
||||
public struct Config: Sendable {
|
||||
/// Host to bind the server to (default: 0.0.0.0 for LAN access)
|
||||
public let host: String
|
||||
|
||||
/// Port to listen on (default: 50051)
|
||||
public let port: Int
|
||||
|
||||
/// Optional API key for authentication via gRPC metadata
|
||||
public let apiKey: String?
|
||||
|
||||
/// Initialize configuration from environment variables
|
||||
public init() {
|
||||
self.host = ProcessInfo.processInfo.environment["GRPC_HOST"] ?? "0.0.0.0"
|
||||
self.port = Int(ProcessInfo.processInfo.environment["GRPC_PORT"] ?? "50051") ?? 50051
|
||||
self.apiKey = ProcessInfo.processInfo.environment["API_KEY"]
|
||||
}
|
||||
|
||||
/// Initialize with explicit values (for testing)
|
||||
public init(host: String, port: Int, apiKey: String? = nil) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.apiKey = apiKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// DO NOT EDIT.
|
||||
// swift-format-ignore-file
|
||||
// swiftlint:disable all
|
||||
//
|
||||
// Generated protocol buffer code for apple_intelligence.proto
|
||||
|
||||
import Foundation
|
||||
import SwiftProtobuf
|
||||
|
||||
// MARK: - Messages
|
||||
|
||||
struct Appleintelligence_CompletionRequest: Sendable, SwiftProtobuf.Message {
|
||||
static let protoMessageName: String = "appleintelligence.CompletionRequest"
|
||||
|
||||
var prompt: String = ""
|
||||
var temperature: Float = 0
|
||||
var maxTokens: Int32 = 0
|
||||
|
||||
var hasTemperature: Bool = false
|
||||
var hasMaxTokens: Bool = false
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
init() {}
|
||||
|
||||
init(prompt: String, temperature: Float? = nil, maxTokens: Int32? = nil) {
|
||||
self.prompt = prompt
|
||||
if let temp = temperature {
|
||||
self.temperature = temp
|
||||
self.hasTemperature = true
|
||||
}
|
||||
if let tokens = maxTokens {
|
||||
self.maxTokens = tokens
|
||||
self.hasMaxTokens = true
|
||||
}
|
||||
}
|
||||
|
||||
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
||||
switch fieldNumber {
|
||||
case 1: try decoder.decodeSingularStringField(value: &prompt)
|
||||
case 2:
|
||||
try decoder.decodeSingularFloatField(value: &temperature)
|
||||
hasTemperature = true
|
||||
case 3:
|
||||
try decoder.decodeSingularInt32Field(value: &maxTokens)
|
||||
hasMaxTokens = true
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
||||
if !prompt.isEmpty {
|
||||
try visitor.visitSingularStringField(value: prompt, fieldNumber: 1)
|
||||
}
|
||||
if hasTemperature {
|
||||
try visitor.visitSingularFloatField(value: temperature, fieldNumber: 2)
|
||||
}
|
||||
if hasMaxTokens {
|
||||
try visitor.visitSingularInt32Field(value: maxTokens, fieldNumber: 3)
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.prompt == rhs.prompt && lhs.temperature == rhs.temperature && lhs.maxTokens == rhs.maxTokens && lhs.unknownFields == rhs.unknownFields
|
||||
}
|
||||
|
||||
func isEqualTo(message: any SwiftProtobuf.Message) -> Bool {
|
||||
guard let other = message as? Self else { return false }
|
||||
return self == other
|
||||
}
|
||||
}
|
||||
|
||||
struct Appleintelligence_CompletionResponse: Sendable, SwiftProtobuf.Message {
|
||||
static let protoMessageName: String = "appleintelligence.CompletionResponse"
|
||||
|
||||
var id: String = ""
|
||||
var text: String = ""
|
||||
var finishReason: String = ""
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
init() {}
|
||||
|
||||
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
||||
switch fieldNumber {
|
||||
case 1: try decoder.decodeSingularStringField(value: &id)
|
||||
case 2: try decoder.decodeSingularStringField(value: &text)
|
||||
case 3: try decoder.decodeSingularStringField(value: &finishReason)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
||||
if !id.isEmpty {
|
||||
try visitor.visitSingularStringField(value: id, fieldNumber: 1)
|
||||
}
|
||||
if !text.isEmpty {
|
||||
try visitor.visitSingularStringField(value: text, fieldNumber: 2)
|
||||
}
|
||||
if !finishReason.isEmpty {
|
||||
try visitor.visitSingularStringField(value: finishReason, fieldNumber: 3)
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.id == rhs.id && lhs.text == rhs.text && lhs.finishReason == rhs.finishReason && lhs.unknownFields == rhs.unknownFields
|
||||
}
|
||||
|
||||
func isEqualTo(message: any SwiftProtobuf.Message) -> Bool {
|
||||
guard let other = message as? Self else { return false }
|
||||
return self == other
|
||||
}
|
||||
}
|
||||
|
||||
struct Appleintelligence_CompletionChunk: Sendable, SwiftProtobuf.Message {
|
||||
static let protoMessageName: String = "appleintelligence.CompletionChunk"
|
||||
|
||||
var id: String = ""
|
||||
var delta: String = ""
|
||||
var isFinal: Bool = false
|
||||
var finishReason: String = ""
|
||||
|
||||
var hasFinishReason: Bool {
|
||||
!finishReason.isEmpty
|
||||
}
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
init() {}
|
||||
|
||||
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
||||
switch fieldNumber {
|
||||
case 1: try decoder.decodeSingularStringField(value: &id)
|
||||
case 2: try decoder.decodeSingularStringField(value: &delta)
|
||||
case 3: try decoder.decodeSingularBoolField(value: &isFinal)
|
||||
case 4: try decoder.decodeSingularStringField(value: &finishReason)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
||||
if !id.isEmpty {
|
||||
try visitor.visitSingularStringField(value: id, fieldNumber: 1)
|
||||
}
|
||||
if !delta.isEmpty {
|
||||
try visitor.visitSingularStringField(value: delta, fieldNumber: 2)
|
||||
}
|
||||
if isFinal {
|
||||
try visitor.visitSingularBoolField(value: isFinal, fieldNumber: 3)
|
||||
}
|
||||
if !finishReason.isEmpty {
|
||||
try visitor.visitSingularStringField(value: finishReason, fieldNumber: 4)
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.id == rhs.id && lhs.delta == rhs.delta && lhs.isFinal == rhs.isFinal && lhs.finishReason == rhs.finishReason && lhs.unknownFields == rhs.unknownFields
|
||||
}
|
||||
|
||||
func isEqualTo(message: any SwiftProtobuf.Message) -> Bool {
|
||||
guard let other = message as? Self else { return false }
|
||||
return self == other
|
||||
}
|
||||
}
|
||||
|
||||
struct Appleintelligence_HealthRequest: Sendable, SwiftProtobuf.Message {
|
||||
static let protoMessageName: String = "appleintelligence.HealthRequest"
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
init() {}
|
||||
|
||||
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
while let _ = try decoder.nextFieldNumber() {}
|
||||
}
|
||||
|
||||
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.unknownFields == rhs.unknownFields
|
||||
}
|
||||
|
||||
func isEqualTo(message: any SwiftProtobuf.Message) -> Bool {
|
||||
guard let other = message as? Self else { return false }
|
||||
return self == other
|
||||
}
|
||||
}
|
||||
|
||||
struct Appleintelligence_HealthResponse: Sendable, SwiftProtobuf.Message {
|
||||
static let protoMessageName: String = "appleintelligence.HealthResponse"
|
||||
|
||||
var healthy: Bool = false
|
||||
var modelStatus: String = ""
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
init() {}
|
||||
|
||||
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
||||
switch fieldNumber {
|
||||
case 1: try decoder.decodeSingularBoolField(value: &healthy)
|
||||
case 2: try decoder.decodeSingularStringField(value: &modelStatus)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
||||
if healthy {
|
||||
try visitor.visitSingularBoolField(value: healthy, fieldNumber: 1)
|
||||
}
|
||||
if !modelStatus.isEmpty {
|
||||
try visitor.visitSingularStringField(value: modelStatus, fieldNumber: 2)
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
static func ==(lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.healthy == rhs.healthy && lhs.modelStatus == rhs.modelStatus && lhs.unknownFields == rhs.unknownFields
|
||||
}
|
||||
|
||||
func isEqualTo(message: any SwiftProtobuf.Message) -> Bool {
|
||||
guard let other = message as? Self else { return false }
|
||||
return self == other
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
import GRPCCore
|
||||
import GRPCProtobuf
|
||||
import GRPCNIOTransportHTTP2
|
||||
|
||||
/// gRPC service provider for Apple Intelligence
|
||||
public struct AppleIntelligenceProvider: RegistrableRPCService {
|
||||
/// Service descriptor
|
||||
public static let serviceDescriptor = ServiceDescriptor(
|
||||
fullyQualifiedService: "appleintelligence.AppleIntelligence"
|
||||
)
|
||||
|
||||
/// Method descriptors
|
||||
enum Methods {
|
||||
static let complete = MethodDescriptor(
|
||||
service: AppleIntelligenceProvider.serviceDescriptor,
|
||||
method: "Complete"
|
||||
)
|
||||
static let streamComplete = MethodDescriptor(
|
||||
service: AppleIntelligenceProvider.serviceDescriptor,
|
||||
method: "StreamComplete"
|
||||
)
|
||||
static let health = MethodDescriptor(
|
||||
service: AppleIntelligenceProvider.serviceDescriptor,
|
||||
method: "Health"
|
||||
)
|
||||
}
|
||||
|
||||
/// The underlying AI service
|
||||
private let service: AppleIntelligenceService
|
||||
|
||||
/// Optional API key for authentication
|
||||
private let apiKey: String?
|
||||
|
||||
public init(service: AppleIntelligenceService, apiKey: String? = nil) {
|
||||
self.service = service
|
||||
self.apiKey = apiKey
|
||||
}
|
||||
|
||||
public func registerMethods<Transport: ServerTransport>(with router: inout RPCRouter<Transport>) {
|
||||
// Register Complete method (unary)
|
||||
router.registerHandler(
|
||||
forMethod: Methods.complete,
|
||||
deserializer: ProtobufDeserializer<Appleintelligence_CompletionRequest>(),
|
||||
serializer: ProtobufSerializer<Appleintelligence_CompletionResponse>()
|
||||
) { request, context in
|
||||
try self.validateApiKey(metadata: request.metadata)
|
||||
|
||||
// Collect the single message from the request stream
|
||||
var requestMessage: Appleintelligence_CompletionRequest?
|
||||
for try await message in request.messages {
|
||||
requestMessage = message
|
||||
break
|
||||
}
|
||||
|
||||
guard let message = requestMessage else {
|
||||
throw RPCError(code: .invalidArgument, message: "No request message received")
|
||||
}
|
||||
|
||||
let text = try await self.service.complete(
|
||||
prompt: message.prompt,
|
||||
temperature: message.hasTemperature ? message.temperature : nil,
|
||||
maxTokens: message.hasMaxTokens ? Int(message.maxTokens) : nil
|
||||
)
|
||||
|
||||
var response = Appleintelligence_CompletionResponse()
|
||||
response.id = UUID().uuidString
|
||||
response.text = text
|
||||
response.finishReason = "stop"
|
||||
|
||||
return StreamingServerResponse(single: ServerResponse(message: response))
|
||||
}
|
||||
|
||||
// Register StreamComplete method (server streaming)
|
||||
router.registerHandler(
|
||||
forMethod: Methods.streamComplete,
|
||||
deserializer: ProtobufDeserializer<Appleintelligence_CompletionRequest>(),
|
||||
serializer: ProtobufSerializer<Appleintelligence_CompletionChunk>()
|
||||
) { request, context in
|
||||
try self.validateApiKey(metadata: request.metadata)
|
||||
|
||||
// Collect the single message from the request stream
|
||||
var requestMessage: Appleintelligence_CompletionRequest?
|
||||
for try await message in request.messages {
|
||||
requestMessage = message
|
||||
break
|
||||
}
|
||||
|
||||
guard let message = requestMessage else {
|
||||
throw RPCError(code: .invalidArgument, message: "No request message received")
|
||||
}
|
||||
|
||||
let completionId = UUID().uuidString
|
||||
let prompt = message.prompt
|
||||
let temperature = message.hasTemperature ? message.temperature : nil
|
||||
let maxTokens = message.hasMaxTokens ? Int(message.maxTokens) : nil
|
||||
|
||||
return StreamingServerResponse { writer in
|
||||
let stream = await self.service.streamComplete(
|
||||
prompt: prompt,
|
||||
temperature: temperature,
|
||||
maxTokens: maxTokens
|
||||
)
|
||||
|
||||
var lastContent = ""
|
||||
for try await partialResponse in stream {
|
||||
// Calculate the delta (new text since last response)
|
||||
let delta: String
|
||||
if partialResponse.hasPrefix(lastContent) {
|
||||
delta = String(partialResponse.dropFirst(lastContent.count))
|
||||
} else {
|
||||
delta = partialResponse
|
||||
}
|
||||
lastContent = partialResponse
|
||||
|
||||
if !delta.isEmpty {
|
||||
var chunk = Appleintelligence_CompletionChunk()
|
||||
chunk.id = completionId
|
||||
chunk.delta = delta
|
||||
chunk.isFinal = false
|
||||
try await writer.write(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// Send final chunk
|
||||
var finalChunk = Appleintelligence_CompletionChunk()
|
||||
finalChunk.id = completionId
|
||||
finalChunk.delta = ""
|
||||
finalChunk.isFinal = true
|
||||
finalChunk.finishReason = "stop"
|
||||
try await writer.write(finalChunk)
|
||||
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
// Register Health method (unary)
|
||||
router.registerHandler(
|
||||
forMethod: Methods.health,
|
||||
deserializer: ProtobufDeserializer<Appleintelligence_HealthRequest>(),
|
||||
serializer: ProtobufSerializer<Appleintelligence_HealthResponse>()
|
||||
) { request, context in
|
||||
// Consume request messages (empty for health check)
|
||||
for try await _ in request.messages {}
|
||||
|
||||
let isHealthy = await self.service.isAvailable
|
||||
let modelStatus = await self.service.getModelStatus()
|
||||
|
||||
var response = Appleintelligence_HealthResponse()
|
||||
response.healthy = isHealthy
|
||||
response.modelStatus = modelStatus
|
||||
|
||||
return StreamingServerResponse(single: ServerResponse(message: response))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate API key if configured
|
||||
private func validateApiKey(metadata: Metadata) throws {
|
||||
guard let expectedKey = apiKey else {
|
||||
return // No API key required
|
||||
}
|
||||
|
||||
// Look for Authorization header in metadata
|
||||
let authValues = metadata["authorization"]
|
||||
guard let authHeader = authValues.first(where: { _ in true }),
|
||||
case .string(let authString) = authHeader,
|
||||
authString.hasPrefix("Bearer ") else {
|
||||
throw RPCError(code: .unauthenticated, message: "Missing or invalid Authorization header")
|
||||
}
|
||||
|
||||
let providedKey = String(authString.dropFirst("Bearer ".count))
|
||||
guard providedKey == expectedKey else {
|
||||
throw RPCError(code: .unauthenticated, message: "Invalid API key")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
|
||||
/// Errors that can occur when using Apple Intelligence
|
||||
public enum AppleIntelligenceError: Error, CustomStringConvertible, Sendable {
|
||||
case modelNotAvailable
|
||||
case generationFailed(String)
|
||||
case sessionCreationFailed
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .modelNotAvailable:
|
||||
return "Apple Intelligence model is not available on this device"
|
||||
case .generationFailed(let reason):
|
||||
return "Generation failed: \(reason)"
|
||||
case .sessionCreationFailed:
|
||||
return "Failed to create language model session"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service wrapper for Apple Intelligence Foundation Models
|
||||
public actor AppleIntelligenceService {
|
||||
/// The language model session
|
||||
private var session: LanguageModelSession?
|
||||
|
||||
/// Whether the model is available
|
||||
public private(set) var isAvailable: Bool = false
|
||||
|
||||
/// Initialize and check model availability
|
||||
public init() async {
|
||||
await checkAvailability()
|
||||
}
|
||||
|
||||
/// Check if Apple Intelligence is available
|
||||
private func checkAvailability() async {
|
||||
let availability = SystemLanguageModel.default.availability
|
||||
switch availability {
|
||||
case .available:
|
||||
isAvailable = true
|
||||
session = LanguageModelSession()
|
||||
case .unavailable:
|
||||
isAvailable = false
|
||||
@unknown default:
|
||||
isAvailable = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current model status as a string
|
||||
public func getModelStatus() -> String {
|
||||
let availability = SystemLanguageModel.default.availability
|
||||
switch availability {
|
||||
case .available:
|
||||
return "available"
|
||||
case .unavailable(let reason):
|
||||
return "unavailable: \(reason)"
|
||||
@unknown default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a completion for the given prompt (non-streaming)
|
||||
public func complete(prompt: String, temperature: Float?, maxTokens: Int?) async throws -> String {
|
||||
guard isAvailable, let session = session else {
|
||||
throw AppleIntelligenceError.modelNotAvailable
|
||||
}
|
||||
|
||||
let response = try await session.respond(to: prompt)
|
||||
return response.content
|
||||
}
|
||||
|
||||
/// Generate a streaming completion for the given prompt
|
||||
public func streamComplete(
|
||||
prompt: String,
|
||||
temperature: Float?,
|
||||
maxTokens: Int?
|
||||
) -> AsyncThrowingStream<String, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
guard self.isAvailable, let session = self.session else {
|
||||
continuation.finish(throwing: AppleIntelligenceError.modelNotAvailable)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let stream = session.streamResponse(to: prompt)
|
||||
for try await partialResponse in stream {
|
||||
continuation.yield(partialResponse.content)
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: AppleIntelligenceError.generationFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user