@@ -10,11 +10,18 @@ import ExecuTorchLLM
1010import SwiftUI
1111import UniformTypeIdentifiers
1212
13+ #if os(iOS)
14+ import UIKit
15+ #elseif os(macOS)
16+ import AppKit
17+ #endif
18+
1319class RunnerHolder : ObservableObject {
1420 var textRunner : TextRunner ?
1521 var multimodalRunner : MultimodalRunner ?
1622}
1723
24+ #if os(iOS)
1825extension 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
95182struct 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
9021222extension 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