Skip to content

Commit aa16547

Browse files
authored
etLLM macOS build (#198)
1 parent aa11032 commit aa16547

10 files changed

Lines changed: 698 additions & 7 deletions

File tree

llm/apple/Application/ContentView.swift

Lines changed: 325 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@ import ExecuTorchLLM
1010
import SwiftUI
1111
import UniformTypeIdentifiers
1212

13+
#if os(iOS)
14+
import UIKit
15+
#elseif os(macOS)
16+
import AppKit
17+
#endif
18+
1319
class RunnerHolder: ObservableObject {
1420
var textRunner: TextRunner?
1521
var multimodalRunner: MultimodalRunner?
1622
}
1723

24+
#if os(iOS)
1825
extension UIImage {
1926
func centerCropped(to sideSize: CGFloat) -> UIImage {
2027
precondition(sideSize > 0)
@@ -91,6 +98,86 @@ extension UIImage {
9198
)
9299
}
93100
}
101+
#elseif os(macOS)
102+
extension NSImage {
103+
func centerCropped(to sideSize: CGFloat) -> NSImage {
104+
precondition(sideSize > 0)
105+
let newImage = NSImage(size: NSSize(width: sideSize, height: sideSize))
106+
newImage.lockFocus()
107+
let scaleFactor = max(sideSize / size.width, sideSize / size.height)
108+
let scaledWidth = size.width * scaleFactor
109+
let scaledHeight = size.height * scaleFactor
110+
let originX = (sideSize - scaledWidth) / 2
111+
let originY = (sideSize - scaledHeight) / 2
112+
draw(in: NSRect(x: originX, y: originY, width: scaledWidth, height: scaledHeight),
113+
from: NSRect(origin: .zero, size: size),
114+
operation: .copy,
115+
fraction: 1.0)
116+
newImage.unlockFocus()
117+
return newImage
118+
}
119+
120+
func rgbBytes() -> [UInt8]? {
121+
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
122+
let pixelWidth = Int(cgImage.width)
123+
let pixelHeight = Int(cgImage.height)
124+
let pixelCount = pixelWidth * pixelHeight
125+
let bytesPerPixel = 4
126+
let bytesPerRow = pixelWidth * bytesPerPixel
127+
var rgbaBuffer = [UInt8](repeating: 0, count: pixelCount * bytesPerPixel)
128+
guard let context = CGContext(
129+
data: &rgbaBuffer,
130+
width: pixelWidth,
131+
height: pixelHeight,
132+
bitsPerComponent: 8,
133+
bytesPerRow: bytesPerRow,
134+
space: CGColorSpaceCreateDeviceRGB(),
135+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
136+
) else { return nil }
137+
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight))
138+
var rgbBytes = [UInt8](repeating: 0, count: pixelCount * 3)
139+
for pixelIndex in 0..<pixelCount {
140+
let sourceIndex = pixelIndex * bytesPerPixel
141+
rgbBytes[pixelIndex] = rgbaBuffer[sourceIndex + 0]
142+
rgbBytes[pixelIndex + pixelCount] = rgbaBuffer[sourceIndex + 1]
143+
rgbBytes[pixelIndex + 2 * pixelCount] = rgbaBuffer[sourceIndex + 2]
144+
}
145+
return rgbBytes
146+
}
147+
148+
func rgbBytesNormalized(mean: [Float] = [0, 0, 0], std: [Float] = [1, 1, 1]) -> [Float]? {
149+
precondition(mean.count == 3 && std.count == 3)
150+
precondition(std[0] != 0 && std[1] != 0 && std[2] != 0)
151+
guard let rgbBytes = rgbBytes() else { return nil }
152+
let pixelCount = rgbBytes.count / 3
153+
var rgbBytesNormalized = [Float](repeating: 0, count: pixelCount * 3)
154+
for pixelIndex in 0..<pixelCount {
155+
rgbBytesNormalized[pixelIndex] = (Float(rgbBytes[pixelIndex]) / 255.0 - mean[0]) / std[0]
156+
rgbBytesNormalized[pixelIndex + pixelCount] = (Float(rgbBytes[pixelIndex + pixelCount]) / 255.0 - mean[1]) / std[1]
157+
rgbBytesNormalized[pixelIndex + 2 * pixelCount] = (Float(rgbBytes[pixelIndex + 2 * pixelCount]) / 255.0 - mean[2]) / std[2]
158+
}
159+
return rgbBytesNormalized
160+
}
161+
162+
func asImage(_ sideSize: CGFloat) -> ExecuTorchLLM.Image {
163+
return Image(
164+
data: Data(centerCropped(to: sideSize).rgbBytes() ?? []),
165+
width: Int(sideSize),
166+
height: Int(sideSize),
167+
channels: 3
168+
)
169+
}
170+
171+
func asNormalizedImage(_ sideSize: CGFloat, mean: [Float] = [0.485, 0.456, 0.406], std: [Float] = [0.229, 0.224, 0.225]) -> ExecuTorchLLM.Image {
172+
return Image(
173+
float: (centerCropped(to: sideSize).rgbBytesNormalized(mean: mean, std: std) ?? []).withUnsafeBufferPointer { Data(buffer: $0) },
174+
width: Int(sideSize),
175+
height: Int(sideSize),
176+
channels: 3
177+
)
178+
}
179+
}
180+
#endif
94181

95182
struct ContentView: View {
96183
@State private var prompt = ""
@@ -108,8 +195,10 @@ struct ContentView: View {
108195
@StateObject private var resourceMonitor = ResourceMonitor()
109196
@StateObject private var logManager = LogManager()
110197
@State private var isImagePickerPresented = false
111-
@State private var selectedImage: UIImage?
198+
@State private var selectedImage: PlatformImage?
199+
#if os(iOS)
112200
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .photoLibrary
201+
#endif
113202
@State private var showingSettings = false
114203
@FocusState private var textFieldFocused: Bool
115204
@State private var lastPreloadedKey: String?
@@ -169,6 +258,217 @@ struct ContentView: View {
169258
private var isInputEnabled: Bool { resourceManager.isModelValid && resourceManager.isTokenizerValid }
170259

171260
var body: some View {
261+
#if os(macOS)
262+
macOSBody
263+
#else
264+
iOSBody
265+
#endif
266+
}
267+
268+
#if os(macOS)
269+
@ViewBuilder
270+
private var macOSBody: some View {
271+
NavigationSplitView {
272+
// Left sidebar with configuration
273+
VStack(alignment: .leading, spacing: 12) {
274+
Text("Model")
275+
.font(.headline)
276+
.foregroundColor(.secondary)
277+
.padding(.top, 8)
278+
279+
Button(action: { pickerType = .model }) {
280+
HStack {
281+
Image(systemName: "cpu")
282+
Text(modelTitle)
283+
.lineLimit(1)
284+
.truncationMode(.middle)
285+
Spacer()
286+
}
287+
.padding(8)
288+
.background(Color.gray.opacity(0.1))
289+
.cornerRadius(8)
290+
}
291+
.buttonStyle(.plain)
292+
293+
Button(action: { pickerType = .tokenizer }) {
294+
HStack {
295+
Image(systemName: "doc.text")
296+
Text(tokenizerTitle)
297+
.lineLimit(1)
298+
.truncationMode(.middle)
299+
Spacer()
300+
}
301+
.padding(8)
302+
.background(Color.gray.opacity(0.1))
303+
.cornerRadius(8)
304+
}
305+
.buttonStyle(.plain)
306+
307+
Divider()
308+
.padding(.vertical, 8)
309+
310+
Text("Memory")
311+
.font(.headline)
312+
.foregroundColor(.secondary)
313+
314+
VStack(alignment: .leading, spacing: 4) {
315+
HStack {
316+
Text("Used:")
317+
Spacer()
318+
Text("\(resourceMonitor.usedMemory) MB")
319+
.monospacedDigit()
320+
}
321+
HStack {
322+
Text("Available:")
323+
Spacer()
324+
Text("\(resourceMonitor.availableMemory) MB")
325+
.monospacedDigit()
326+
}
327+
}
328+
.font(.caption)
329+
.onAppear { resourceMonitor.start() }
330+
.onDisappear { resourceMonitor.stop() }
331+
332+
Divider()
333+
.padding(.vertical, 8)
334+
335+
Button(action: { showingLogs = true }) {
336+
HStack {
337+
Image(systemName: "list.bullet.rectangle")
338+
Text("Logs")
339+
Spacer()
340+
}
341+
.padding(8)
342+
.background(Color.gray.opacity(0.1))
343+
.cornerRadius(8)
344+
}
345+
.buttonStyle(.plain)
346+
347+
Spacer()
348+
}
349+
.padding(.horizontal)
350+
.frame(minWidth: 200, maxWidth: 250)
351+
.navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280)
352+
} detail: {
353+
// Main chat area
354+
VStack(spacing: 0) {
355+
MessageListView(messages: $messages)
356+
.frame(maxWidth: .infinity, maxHeight: .infinity)
357+
358+
// Input bar
359+
HStack {
360+
Button(action: { selectImageOnMac() }) {
361+
Image(systemName: "photo.on.rectangle")
362+
.resizable()
363+
.scaledToFit()
364+
.frame(width: 24, height: 24)
365+
}
366+
.buttonStyle(.plain)
367+
368+
if resourceManager.isModelValid && ModelType.fromPath(resourceManager.modelPath) == .qwen3 {
369+
Button(action: {
370+
thinkingMode.toggle()
371+
showThinkingModeNotification = true
372+
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
373+
showThinkingModeNotification = false
374+
}
375+
}) {
376+
Image(systemName: "brain")
377+
.resizable()
378+
.scaledToFit()
379+
.frame(width: 24, height: 24)
380+
.foregroundColor(thinkingMode ? .blue : .gray)
381+
}
382+
.buttonStyle(.plain)
383+
}
384+
385+
TextField(placeholder, text: $prompt, axis: .vertical)
386+
.padding(8)
387+
.background(Color.gray.opacity(0.1))
388+
.cornerRadius(20)
389+
.lineLimit(1...10)
390+
.overlay(
391+
RoundedRectangle(cornerRadius: 20)
392+
.stroke(isInputEnabled ? Color.blue : Color.gray, lineWidth: 1)
393+
)
394+
.disabled(!isInputEnabled)
395+
.focused($textFieldFocused)
396+
.onSubmit {
397+
if !prompt.isEmpty && isInputEnabled && !isGenerating {
398+
generate()
399+
}
400+
}
401+
402+
Button(action: isGenerating ? stop : generate) {
403+
Image(systemName: isGenerating ? "stop.circle" : "arrowshape.up.circle.fill")
404+
.resizable()
405+
.aspectRatio(contentMode: .fit)
406+
.frame(height: 28)
407+
}
408+
.buttonStyle(.plain)
409+
.disabled(isGenerating ? shouldStopGenerating : (!isInputEnabled || prompt.isEmpty))
410+
}
411+
.padding(10)
412+
}
413+
.overlay {
414+
if showThinkingModeNotification {
415+
Text(thinkingMode ? "Thinking mode enabled" : "Thinking mode disabled")
416+
.padding(8)
417+
.background(Color(NSColor.controlBackgroundColor))
418+
.cornerRadius(8)
419+
.transition(.opacity)
420+
.animation(.easeInOut(duration: 0.2), value: showThinkingModeNotification)
421+
}
422+
}
423+
}
424+
.sheet(isPresented: $showingLogs) {
425+
VStack(spacing: 0) {
426+
HStack {
427+
Text("Logs")
428+
.font(.headline)
429+
Spacer()
430+
Button(action: { logManager.clear() }) {
431+
Image(systemName: "trash")
432+
}
433+
.help("Clear logs")
434+
Button("Done") {
435+
showingLogs = false
436+
}
437+
}
438+
.padding()
439+
.background(Color(NSColor.controlBackgroundColor))
440+
441+
Divider()
442+
443+
LogView(logManager: logManager)
444+
}
445+
.frame(minWidth: 600, minHeight: 400)
446+
}
447+
.fileImporter(
448+
isPresented: Binding<Bool>(
449+
get: { pickerType != nil },
450+
set: { if !$0 { pickerType = nil } }
451+
),
452+
allowedContentTypes: allowedContentTypes(),
453+
allowsMultipleSelection: false
454+
) { [pickerType] result in
455+
handleFileImportResult(pickerType, result)
456+
}
457+
.onAppear {
458+
do {
459+
try resourceManager.createDirectoriesIfNeeded()
460+
} catch {
461+
withAnimation {
462+
messages.append(Message(type: .info, text: "Error creating content directories: \(error.localizedDescription)"))
463+
}
464+
}
465+
}
466+
}
467+
#endif
468+
469+
#if os(iOS)
470+
@ViewBuilder
471+
private var iOSBody: some View {
172472
NavigationView {
173473
ZStack {
174474
VStack {
@@ -365,13 +665,33 @@ struct ContentView: View {
365665
}
366666
.navigationViewStyle(StackNavigationViewStyle())
367667
}
668+
#endif
368669

369670
private func addSelectedImageMessage() {
370671
if let selectedImage {
371672
messages.append(Message(image: selectedImage))
372673
}
373674
}
374675

676+
#if os(macOS)
677+
private func selectImageOnMac() {
678+
let panel = NSOpenPanel()
679+
panel.allowsMultipleSelection = false
680+
panel.canChooseDirectories = false
681+
panel.canChooseFiles = true
682+
panel.allowedContentTypes = [.image, .png, .jpeg, .gif, .heic]
683+
panel.message = "Select an image"
684+
panel.prompt = "Select"
685+
686+
if panel.runModal() == .OK {
687+
if let url = panel.url, let image = NSImage(contentsOf: url) {
688+
selectedImage = image
689+
addSelectedImageMessage()
690+
}
691+
}
692+
}
693+
#endif
694+
375695
private func generate() {
376696
guard !prompt.isEmpty else { return }
377697
isGenerating = true
@@ -901,6 +1221,10 @@ extension ContentView {
9011221

9021222
extension View {
9031223
func hideKeyboard() {
1224+
#if os(iOS)
9041225
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
1226+
#elseif os(macOS)
1227+
NSApp.keyWindow?.makeFirstResponder(nil)
1228+
#endif
9051229
}
9061230
}

0 commit comments

Comments
 (0)