Skip to content

Instantly share code, notes, and snippets.

@yanyaoer
Last active October 10, 2024 04:58
Show Gist options
  • Save yanyaoer/74dc6c4d0e5084c1580aa362820a7264 to your computer and use it in GitHub Desktop.
Save yanyaoer/74dc6c4d0e5084c1580aa362820a7264 to your computer and use it in GitHub Desktop.
minimal ui for ollama
#!/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