@@ -33,6 +33,19 @@ final class LLMViewModel {
3333 private( set) var modelSupportsStreaming = true
3434 private( set) var currentConversation : Conversation ?
3535
36+ // MARK: - LoRA Adapter State
37+
38+ private( set) var loraAdapters : [ LoRAAdapterInfo ] = [ ]
39+ private( set) var isLoadingLoRA = false
40+
41+ // MARK: - LoRA Adapter Download State
42+ // TODO: [Portal Integration] Remove demo adapter download state once portal delivers adapters OTA.
43+
44+ private( set) var availableDemoAdapters : [ DemoLoRAAdapter ] = [ ]
45+ private( set) var adapterDownloadProgress : [ String : Double ] = [ : ]
46+ private( set) var downloadedAdapterPaths : [ String : String ] = [ : ]
47+ private( set) var isDownloadingAdapter : [ String : Bool ] = [ : ]
48+
3649 // MARK: - User Settings
3750
3851 var currentInput = " "
@@ -308,6 +321,142 @@ final class LLMViewModel {
308321 clearChat ( )
309322 }
310323
324+ // MARK: - LoRA Adapter Management
325+
326+ func loadLoraAdapter( path: String , scale: Float ) async {
327+ isLoadingLoRA = true
328+ error = nil
329+ do {
330+ try await RunAnywhere . loadLoraAdapter ( LoRAAdapterConfig ( path: path, scale: scale) )
331+ await refreshLoraAdapters ( )
332+ logger. info ( " LoRA adapter loaded: \( path) (scale= \( scale) ) " )
333+ } catch {
334+ logger. error ( " Failed to load LoRA adapter: \( error) " )
335+ self . error = error
336+ }
337+ isLoadingLoRA = false
338+ }
339+
340+ func removeLoraAdapter( path: String ) async {
341+ do {
342+ try await RunAnywhere . removeLoraAdapter ( path)
343+ await refreshLoraAdapters ( )
344+ } catch {
345+ logger. error ( " Failed to remove LoRA adapter: \( error) " )
346+ self . error = error
347+ }
348+ }
349+
350+ func clearLoraAdapters( ) async {
351+ do {
352+ try await RunAnywhere . clearLoraAdapters ( )
353+ loraAdapters = [ ]
354+ } catch {
355+ logger. error ( " Failed to clear LoRA adapters: \( error) " )
356+ self . error = error
357+ }
358+ }
359+
360+ func refreshLoraAdapters( ) async {
361+ do {
362+ loraAdapters = try await RunAnywhere . getLoadedLoraAdapters ( )
363+ } catch {
364+ logger. error ( " Failed to refresh LoRA adapters: \( error) " )
365+ }
366+ }
367+
368+ // MARK: - Demo LoRA Adapter Download
369+ // TODO: [Portal Integration] Remove demo adapter download logic once portal delivers adapters OTA.
370+
371+ /// Refreshes the list of available demo adapters for the currently loaded model.
372+ func refreshAvailableDemoAdapters( ) {
373+ guard let modelId = ModelListViewModel . shared. currentModel? . id else {
374+ availableDemoAdapters = [ ]
375+ return
376+ }
377+ availableDemoAdapters = DemoLoRAAdapterCatalog . adapters ( forModelId: modelId)
378+ syncDownloadedAdapterPaths ( )
379+ }
380+
381+ /// Checks if a demo adapter's file already exists on disk.
382+ func isAdapterDownloaded( _ adapter: DemoLoRAAdapter ) -> Bool {
383+ downloadedAdapterPaths [ adapter. id] != nil
384+ }
385+
386+ /// Returns the local file path for a downloaded adapter, or nil.
387+ func localPath( for adapter: DemoLoRAAdapter ) -> String ? {
388+ downloadedAdapterPaths [ adapter. id]
389+ }
390+
391+ /// Downloads a demo adapter from its URL, then loads it.
392+ func downloadAndLoadAdapter( _ adapter: DemoLoRAAdapter , scale: Float ) async {
393+ guard isDownloadingAdapter [ adapter. id] != true else { return }
394+
395+ isDownloadingAdapter [ adapter. id] = true
396+ adapterDownloadProgress [ adapter. id] = 0.0
397+ error = nil
398+
399+ do {
400+ let localPath : String
401+ if let existing = downloadedAdapterPaths [ adapter. id] {
402+ localPath = existing
403+ } else {
404+ localPath = try await downloadAdapter ( adapter)
405+ }
406+ await loadLoraAdapter ( path: localPath, scale: scale)
407+ } catch {
408+ logger. error ( " Failed to download/load adapter \( adapter. id) : \( error) " )
409+ self . error = error
410+ }
411+
412+ isDownloadingAdapter [ adapter. id] = false
413+ adapterDownloadProgress [ adapter. id] = nil
414+ }
415+
416+ /// Downloads the adapter file to the LoRA directory.
417+ private func downloadAdapter( _ adapter: DemoLoRAAdapter ) async throws -> String {
418+ let loraDir = Self . loraDownloadDirectory ( )
419+ try FileManager . default. createDirectory ( at: loraDir, withIntermediateDirectories: true )
420+ let destinationURL = loraDir. appendingPathComponent ( adapter. fileName)
421+
422+ if FileManager . default. fileExists ( atPath: destinationURL. path) {
423+ downloadedAdapterPaths [ adapter. id] = destinationURL. path
424+ return destinationURL. path
425+ }
426+
427+ let delegate = DownloadProgressDelegate { [ weak self] progress in
428+ Task { @MainActor in
429+ self ? . adapterDownloadProgress [ adapter. id] = progress
430+ }
431+ }
432+
433+ let ( tempURL, _) = try await URLSession . shared. download ( from: adapter. downloadURL, delegate: delegate)
434+ if FileManager . default. fileExists ( atPath: destinationURL. path) {
435+ try FileManager . default. removeItem ( at: destinationURL)
436+ }
437+ try FileManager . default. moveItem ( at: tempURL, to: destinationURL)
438+
439+ downloadedAdapterPaths [ adapter. id] = destinationURL. path
440+ logger. info ( " Adapter downloaded to \( destinationURL. path) " )
441+ return destinationURL. path
442+ }
443+
444+ /// Scans the LoRA directory to populate downloadedAdapterPaths.
445+ private func syncDownloadedAdapterPaths( ) {
446+ let loraDir = Self . loraDownloadDirectory ( )
447+ for adapter in availableDemoAdapters {
448+ let path = loraDir. appendingPathComponent ( adapter. fileName) . path
449+ if FileManager . default. fileExists ( atPath: path) {
450+ downloadedAdapterPaths [ adapter. id] = path
451+ }
452+ }
453+ }
454+
455+ static func loraDownloadDirectory( ) -> URL {
456+ let docs = FileManager . default. urls ( for: . documentDirectory, in: . userDomainMask) [ 0 ]
457+ return docs. appendingPathComponent ( " LoRA " , isDirectory: true )
458+ }
459+
311460 // MARK: - Private Methods - Message Generation
312461
313462 private func ensureModelIsLoaded( ) async throws {
@@ -387,6 +536,7 @@ final class LLMViewModel {
387536 self . messages. removeFirst ( )
388537 }
389538 self . addSystemMessage ( )
539+ self . refreshAvailableDemoAdapters ( )
390540 }
391541 } else {
392542 await self . checkModelStatus ( )
0 commit comments