Last active
October 10, 2024 04:58
-
-
Save yanyaoer/74dc6c4d0e5084c1580aa362820a7264 to your computer and use it in GitHub Desktop.
minimal ui for ollama
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env xcrun -sdk macosx swift | |
// import Foundation | |
import SwiftUI | |
import AppKit | |
/** | |
Minimal UI for ollama | |
## Dev: | |
$ chmod +x your_script_file | |
$ ./your_script_file | |
## Build: | |
$ swiftc mini-ollama.swift -o mini-ollama | |
# shortcuts example with skhd | |
# ctrl + alt + shift + cmd - k : mini-ollama | |
## Feature: | |
- minimal UI with ollama | |
- always on top of desktop | |
- autoclose with last window | |
- support multiple models and remember the last model | |
**/ | |
class AppDelegate: NSObject, NSApplicationDelegate { | |
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { | |
return true | |
} | |
} | |
struct VisualEffect: NSViewRepresentable { | |
func makeNSView(context: Context) -> NSVisualEffectView { | |
let view = NSVisualEffectView() | |
view.blendingMode = .behindWindow | |
view.state = .active | |
view.material = .underWindowBackground | |
return view | |
} | |
func updateNSView(_ nsView: NSVisualEffectView, context: Context) { } | |
} | |
struct App: SwiftUI.App { | |
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate | |
@State private var input = "" | |
@State private var output = "" | |
@AppStorage("modelname") public var modelname = "llama3.2:latest" | |
@FocusState private var focused: Bool | |
var body: some Scene { | |
WindowGroup { | |
VStack(alignment: .leading) { | |
Popover(modelname: $modelname) | |
LLMInputView | |
Divider() | |
if self.output.count > 0 { | |
LLMOutputView | |
} else { | |
Spacer(minLength: 20) | |
} | |
} | |
.background(VisualEffect().ignoresSafeArea()) | |
.frame(minWidth: 300, minHeight: 150, alignment: .topLeading) | |
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification), perform: { _ in | |
exit(0) | |
}) | |
.onAppear { | |
focused = true | |
} | |
} | |
.windowStyle(HiddenTitleBarWindowStyle()) | |
.defaultSize(width: 0.5, height: 1.0) | |
} | |
private var LLMInputView: some View { | |
TextField("write something..", text: $input).onSubmit { | |
self.output = "" | |
shell("ollama run " + self.modelname + " '" + self.input + "' ") { line in | |
DispatchQueue.main.async { | |
self.output += line | |
} | |
} | |
} | |
.textFieldStyle(.plain) | |
.focused($focused) | |
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)) | |
} | |
private var LLMOutputView: some View { | |
ScrollView { | |
Text(self.output) | |
.lineLimit(nil) | |
.textSelection(.enabled) | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.padding(.horizontal, 10) | |
} | |
.defaultScrollAnchor(.bottom) | |
} | |
} | |
struct Popover: View { | |
@State private var ModelData = ["llama3.2:latest"] | |
@Binding public var modelname:String | |
var body: some View { | |
ZStack { | |
Picker("", selection: $modelname) { | |
ForEach(ModelData, id: \.self) { name in | |
Text(name) | |
} | |
} | |
.frame(width: 100, height:10, alignment: .trailing) | |
} | |
.offset(x:0, y:5) | |
.ignoresSafeArea() | |
.frame(maxWidth: .infinity, maxHeight: 0, alignment: .trailing) | |
.onAppear() { | |
// FIXME: parse error when multiple line output | |
shell("ollama list", asyncTask: true) { line in | |
DispatchQueue.main.async { | |
updateModelData(mData: line) | |
} | |
} | |
} | |
} | |
func updateModelData(mData: String) { | |
self.ModelData = mData.split(separator: "\n").compactMap { line in | |
let name = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true).first.map {String($0)} | |
return name?.uppercased() == "NAME" ? nil : name | |
} | |
} | |
} | |
func shell(_ command: String, asyncTask: Bool = true, outputHandler: @escaping (String) -> Void) { | |
let task = Process() | |
task.launchPath = "/bin/sh" | |
task.arguments = ["-c", command] | |
let pipe = Pipe() | |
task.standardOutput = pipe | |
task.standardError = pipe | |
if !asyncTask { | |
let data = pipe.fileHandleForReading.readDataToEndOfFile() | |
let output = String(data: data, encoding: .utf8) ?? "" | |
outputHandler(output) | |
return | |
} | |
pipe.fileHandleForReading.waitForDataInBackgroundAndNotify() | |
NotificationCenter.default.addObserver(forName: .NSFileHandleDataAvailable, object: pipe.fileHandleForReading, queue: nil) { notification in | |
let data = pipe.fileHandleForReading.availableData | |
if data.count > 0 { | |
if var line = String(data: data, encoding: .utf8) { | |
// remove color and escape codes from shell | |
line = line.replacingOccurrences(of: "\\[\\?25[h|l]", with: "", options: .regularExpression) | |
.replacingOccurrences(of: "\\x1B(\\[([0-9]{1,3}(;[0-9]{1,2};?)?)?[mGK])?", with: "", options: .regularExpression) | |
// remove braille codes: ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ ⠋ | |
.replacingOccurrences(of: "[\u{2800}-\u{28FF}] ", with: "", options: .regularExpression) | |
outputHandler(line) | |
} | |
pipe.fileHandleForReading.waitForDataInBackgroundAndNotify() | |
} | |
} | |
DispatchQueue.global(qos: .background).async { | |
task.launch() | |
task.waitUntilExit() | |
} | |
} | |
DispatchQueue.main.async { | |
// hide dock icon | |
NSApplication.shared.setActivationPolicy(.accessory) | |
// always on top | |
NSApplication.shared.activate(ignoringOtherApps: true) | |
if let window = NSApplication.shared.windows.first { | |
window.level = .floating | |
} | |
} | |
App.main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment