initial commit
This commit is contained in:
11
Cheetah/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
Cheetah/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
58
Cheetah/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
58
Cheetah/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Cheetah/Assets.xcassets/Contents.json
Normal file
6
Cheetah/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
101
Cheetah/BrowserExtension.swift
Normal file
101
Cheetah/BrowserExtension.swift
Normal file
@ -0,0 +1,101 @@
|
||||
import CheetahIPC
|
||||
|
||||
class BrowserExtensionState: JSONHandler<BrowserExtensionMessage> {
|
||||
@Published var mode: String?
|
||||
@Published var files = [String: String]()
|
||||
@Published var logs = [String: String]()
|
||||
|
||||
var navigationStart = 0
|
||||
var lastUpdate: Date?
|
||||
|
||||
public init() {
|
||||
super.init(respondsTo: IPCMessage.browserExtensionMessage)
|
||||
|
||||
handler = {
|
||||
guard let message = $0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if message.navigationStart > self.navigationStart {
|
||||
self.navigationStart = message.navigationStart
|
||||
self.files.removeAll()
|
||||
self.logs.removeAll()
|
||||
}
|
||||
|
||||
let newMode = message.mode
|
||||
if newMode != self.mode {
|
||||
self.mode = newMode
|
||||
self.files.removeAll()
|
||||
self.logs.removeAll()
|
||||
}
|
||||
|
||||
for (name, content) in message.files {
|
||||
self.files[name] = content
|
||||
}
|
||||
for (name, content) in message.logs {
|
||||
self.logs[name] = content
|
||||
}
|
||||
|
||||
if self.lastUpdate == nil {
|
||||
print("BrowserExtensionState: first message was received!")
|
||||
}
|
||||
|
||||
self.lastUpdate = Date.now
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var codeDescription: String {
|
||||
if files.isEmpty {
|
||||
return "N/A"
|
||||
} else {
|
||||
return files
|
||||
.map { name, content in "[\(name)]\n\(content)" }
|
||||
.joined(separator: "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
var logsDescription: String {
|
||||
if logs.isEmpty {
|
||||
return "N/A"
|
||||
} else {
|
||||
return logs
|
||||
.map { name, content in
|
||||
let recentLines = content.split(separator: "\n").suffix(20).joined(separator: "\n")
|
||||
return "[\(name)]\n\(recentLines)"
|
||||
}
|
||||
.joined(separator: "\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeMessagingManifest: Codable {
|
||||
enum `Type`: String, Codable {
|
||||
case stdio
|
||||
}
|
||||
|
||||
let name: String
|
||||
let description: String
|
||||
let path: String
|
||||
let type: `Type`
|
||||
let allowedExtensions: [String]
|
||||
}
|
||||
|
||||
func installNativeMessagingManifest() throws -> Bool {
|
||||
let manifest = NativeMessagingManifest(
|
||||
name: "cheetah",
|
||||
description: "Cheetah Extension",
|
||||
path: Bundle.main.path(forAuxiliaryExecutable: "ExtensionHelper")!,
|
||||
type: .stdio,
|
||||
allowedExtensions: ["cheetah@phrack.org"])
|
||||
|
||||
let path = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support/Mozilla/NativeMessagingHosts/cheetah.json").absoluteURL.path
|
||||
|
||||
print("Installing native messaging manifest at \(path)")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
|
||||
let contents = try encoder.encode(manifest)
|
||||
return FileManager.default.createFile(atPath: path, contents: contents)
|
||||
}
|
||||
5
Cheetah/Cheetah.entitlements
Normal file
5
Cheetah/Cheetah.entitlements
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
139
Cheetah/CheetahApp.swift
Normal file
139
Cheetah/CheetahApp.swift
Normal file
@ -0,0 +1,139 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import LibWhisper
|
||||
import CheetahIPC
|
||||
|
||||
enum AnswerRequest {
|
||||
case none
|
||||
case answerQuestion
|
||||
case refineAnswer(selection: Range<String.Index>?)
|
||||
case analyzeCode
|
||||
}
|
||||
|
||||
let defaultWhisperModel = "ggml-medium.en"
|
||||
|
||||
class AppViewModel: ObservableObject {
|
||||
@AppStorage("authToken") var authToken: String?
|
||||
@AppStorage("useGPT4") var useGPT4: Bool?
|
||||
|
||||
@Published var devices = [CaptureDevice]()
|
||||
@Published var selectedDevice: CaptureDevice?
|
||||
|
||||
@Published var whisperModel = defaultWhisperModel
|
||||
@Published var downloadState = ModelDownloader.State.pending
|
||||
|
||||
@Published var analyzer: ConversationAnalyzer?
|
||||
@Published var answerRequest = AnswerRequest.none
|
||||
|
||||
@Published var transcript: String?
|
||||
@Published var answer: String?
|
||||
@Published var codeAnswer: String?
|
||||
|
||||
@Published var buttonsAlwaysEnabled = false
|
||||
}
|
||||
|
||||
@main
|
||||
struct CheetahApp: App {
|
||||
@AppStorage("whisperModel") var preferredWhisperModel: String?
|
||||
|
||||
@ObservedObject var viewModel = AppViewModel()
|
||||
|
||||
@State var download: ModelDownloader?
|
||||
@State var stream: WhisperStream?
|
||||
@State var ipcServer: IPCServer?
|
||||
|
||||
var extensionState = BrowserExtensionState()
|
||||
|
||||
func start() async {
|
||||
viewModel.devices = try! CaptureDevice.devices
|
||||
|
||||
let downloadConfig = URLSessionConfiguration.default
|
||||
downloadConfig.allowsExpensiveNetworkAccess = false
|
||||
downloadConfig.waitsForConnectivity = true
|
||||
|
||||
viewModel.whisperModel = preferredWhisperModel ?? defaultWhisperModel
|
||||
let download = ModelDownloader(modelName: viewModel.whisperModel, configuration: downloadConfig)
|
||||
download.$state.assign(to: &viewModel.$downloadState)
|
||||
download.resume()
|
||||
self.download = download
|
||||
|
||||
// Handle messages from ExtensionHelper
|
||||
let server = IPCServer()
|
||||
server.delegate = extensionState
|
||||
server.addSourceForNewLocalMessagePort(name: MessagePortName.browserExtensionServer.rawValue,
|
||||
toRunLoop: RunLoop.main.getCFRunLoop())
|
||||
self.ipcServer = server
|
||||
|
||||
// Install manifest needed for the browser extension to talk to ExtensionHelper
|
||||
_ = try? installNativeMessagingManifest()
|
||||
|
||||
do {
|
||||
for try await request in viewModel.$answerRequest.receive(on: RunLoop.main).values {
|
||||
if let analyzer = viewModel.analyzer {
|
||||
switch request {
|
||||
case .answerQuestion:
|
||||
try await analyzer.answer()
|
||||
viewModel.answer = analyzer.context[.answer]
|
||||
viewModel.codeAnswer = analyzer.context[.codeAnswer]
|
||||
viewModel.answerRequest = .none
|
||||
|
||||
case .refineAnswer(let selection):
|
||||
try await analyzer.answer(refine: true, selection: selection)
|
||||
viewModel.answer = analyzer.context[.answer]
|
||||
viewModel.codeAnswer = analyzer.context[.codeAnswer]
|
||||
viewModel.answerRequest = .none
|
||||
|
||||
case .analyzeCode:
|
||||
try await analyzer.analyzeCode(extensionState: extensionState)
|
||||
viewModel.answer = analyzer.context[.answer]
|
||||
viewModel.answerRequest = .none
|
||||
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
viewModel.answerRequest = .none
|
||||
//TODO: handle error
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(viewModel: viewModel)
|
||||
.task {
|
||||
await start()
|
||||
}
|
||||
.onChange(of: viewModel.selectedDevice) {
|
||||
setCaptureDevice($0)
|
||||
}
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
}
|
||||
|
||||
func setCaptureDevice(_ device: CaptureDevice?) {
|
||||
stream?.cancel()
|
||||
|
||||
guard let device = device,
|
||||
let authToken = viewModel.authToken,
|
||||
let modelURL = download?.modelURL else {
|
||||
return
|
||||
}
|
||||
|
||||
let stream = WhisperStream(model: modelURL, device: device)
|
||||
stream.start()
|
||||
self.stream = stream
|
||||
|
||||
stream.$segments
|
||||
.receive(on: RunLoop.main)
|
||||
.map { String($0.text) }
|
||||
.assign(to: &viewModel.$transcript)
|
||||
|
||||
viewModel.analyzer = ConversationAnalyzer(
|
||||
stream: stream,
|
||||
generator: PromptGenerator(),
|
||||
executor: .init(authToken: authToken, useGPT4: viewModel.useGPT4 ?? false))
|
||||
}
|
||||
}
|
||||
162
Cheetah/ConversationAnalyzer.swift
Normal file
162
Cheetah/ConversationAnalyzer.swift
Normal file
@ -0,0 +1,162 @@
|
||||
import LibWhisper
|
||||
import Combine
|
||||
|
||||
enum ContextKey: String {
|
||||
case transcript
|
||||
case question
|
||||
case answerInCode
|
||||
case answer
|
||||
case previousAnswer
|
||||
case highlightedAnswer
|
||||
case codeAnswer
|
||||
case browserCode
|
||||
case browserLogs
|
||||
}
|
||||
|
||||
typealias AnalysisContext = [ContextKey: String]
|
||||
|
||||
extension AnalysisContext {
|
||||
var answerInCode: Bool {
|
||||
return self[.answerInCode]?.first?.lowercased() == "y"
|
||||
}
|
||||
}
|
||||
|
||||
enum AnalysisError: Error {
|
||||
case missingRequiredContextKey(ContextKey)
|
||||
}
|
||||
|
||||
extension PromptGenerator {
|
||||
func extractQuestion(context: AnalysisContext) throws -> ModelInput? {
|
||||
if let transcript = context[.transcript] {
|
||||
return extractQuestion(transcript: transcript)
|
||||
} else {
|
||||
throw AnalysisError.missingRequiredContextKey(.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func answerQuestion(context: AnalysisContext) throws -> ModelInput? {
|
||||
guard let question = context[.question] else {
|
||||
throw AnalysisError.missingRequiredContextKey(.question)
|
||||
}
|
||||
if context.answerInCode {
|
||||
return nil
|
||||
} else if let answer = context[.previousAnswer] {
|
||||
return answerQuestion(question, previousAnswer: answer)
|
||||
} else if let answer = context[.highlightedAnswer] {
|
||||
return answerQuestion(question, highlightedAnswer: answer)
|
||||
} else {
|
||||
return answerQuestion(question)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCode(context: AnalysisContext) -> ModelInput? {
|
||||
if context.answerInCode, let question = context[.question] {
|
||||
return writeCode(task: question)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeBrowserCode(context: AnalysisContext) -> ModelInput? {
|
||||
if let code = context[.browserCode], let logs = context[.browserLogs] {
|
||||
return analyzeBrowserCode(code, logs: logs, task: context[.question])
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ContextKey {
|
||||
var `set`: (String, inout AnalysisContext) -> () {
|
||||
return { output, context in
|
||||
context[self] = output
|
||||
}
|
||||
}
|
||||
|
||||
func extract(using regexArray: Regex<(Substring, answer: Substring)>...) -> (String, inout AnalysisContext) -> () {
|
||||
return { output, context in
|
||||
for regex in regexArray {
|
||||
if let match = output.firstMatch(of: regex) {
|
||||
context[self] = String(match.answer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let extractQuestion: (String, inout AnalysisContext) -> () = { output, context in
|
||||
let regex = /Extracted question: (?<question>[^\n]+)(?:\nAnswer in code: (?<answerInCode>Yes|No))?/.ignoresCase()
|
||||
if let match = output.firstMatch(of: regex) {
|
||||
context[.question] = String(match.question)
|
||||
if let answerInCode = match.answerInCode {
|
||||
context[.answerInCode] = String(answerInCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalAnswerRegex = /Final answer:\n(?<answer>[-•].+$)/.dotMatchesNewlines()
|
||||
let answerOnlyRegex = /(?<answer>[-•].+$)/.dotMatchesNewlines()
|
||||
|
||||
class ConversationAnalyzer {
|
||||
let stream: WhisperStream
|
||||
let generator: PromptGenerator
|
||||
let executor: OpenAIExecutor
|
||||
|
||||
init(stream: WhisperStream, generator: PromptGenerator, executor: OpenAIExecutor) {
|
||||
self.stream = stream
|
||||
self.generator = generator
|
||||
self.executor = executor
|
||||
}
|
||||
|
||||
var context = [ContextKey: String]()
|
||||
|
||||
func answer(refine: Bool = false, selection: Range<String.Index>? = nil) async throws {
|
||||
let chain = PromptChain(
|
||||
generator: generator.extractQuestion,
|
||||
updateContext: extractQuestion,
|
||||
maxTokens: 250,
|
||||
children: [
|
||||
Prompt(generator: generator.answerQuestion,
|
||||
updateContext: ContextKey.answer.extract(using: finalAnswerRegex, answerOnlyRegex),
|
||||
maxTokens: 500),
|
||||
Prompt(generator: generator.writeCode,
|
||||
updateContext: ContextKey.codeAnswer.set,
|
||||
maxTokens: 1000),
|
||||
])
|
||||
|
||||
var newContext: AnalysisContext = [
|
||||
.transcript: String(stream.segments.text)
|
||||
]
|
||||
|
||||
if refine, let previousAnswer = context[.answer] {
|
||||
if let selection = selection {
|
||||
let highlightedAnswer = previousAnswer[..<selection.lowerBound] + " [start highlighted text] " + previousAnswer[selection] + " [end highlighted text] " + previousAnswer[selection.upperBound...]
|
||||
newContext[.highlightedAnswer] = String(highlightedAnswer)
|
||||
} else {
|
||||
newContext[.previousAnswer] = previousAnswer
|
||||
}
|
||||
}
|
||||
|
||||
context = try await executor.execute(chain: chain, context: newContext)
|
||||
}
|
||||
|
||||
func analyzeCode(extensionState: BrowserExtensionState) async throws {
|
||||
let newContext: AnalysisContext = [
|
||||
.transcript: String(stream.segments.text),
|
||||
.browserCode: extensionState.codeDescription,
|
||||
.browserLogs: extensionState.logsDescription
|
||||
]
|
||||
|
||||
let chain = PromptChain(
|
||||
generator: generator.extractQuestion,
|
||||
updateContext: extractQuestion,
|
||||
maxTokens: 250,
|
||||
children: [
|
||||
Prompt(generator: generator.analyzeBrowserCode,
|
||||
updateContext: ContextKey.answer.set,
|
||||
maxTokens: 500)
|
||||
])
|
||||
|
||||
context = try await executor.execute(chain: chain, context: newContext)
|
||||
}
|
||||
}
|
||||
5
Cheetah/Info.plist
Normal file
5
Cheetah/Info.plist
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
71
Cheetah/ModelDownloader.swift
Normal file
71
Cheetah/ModelDownloader.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
extension Bundle {
|
||||
var displayName: String? {
|
||||
return object(forInfoDictionaryKey: kCFBundleNameKey as String) as? String
|
||||
}
|
||||
}
|
||||
|
||||
var cacheDirectory: URL {
|
||||
let parent = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
return parent.appending(path: Bundle.main.bundleIdentifier!)
|
||||
}
|
||||
|
||||
class ModelDownloader {
|
||||
enum State {
|
||||
case pending
|
||||
case completed
|
||||
case failed(Error?)
|
||||
}
|
||||
|
||||
@Published var state = State.pending
|
||||
|
||||
let baseURL = URL(string: "https://huggingface.co/datasets/ggerganov/whisper.cpp/resolve/main")!
|
||||
|
||||
let modelName: String
|
||||
let session: URLSession
|
||||
let filename: String
|
||||
let modelURL: URL
|
||||
|
||||
var task: URLSessionDownloadTask?
|
||||
|
||||
init(modelName: String, configuration: URLSessionConfiguration = .default) {
|
||||
self.modelName = modelName
|
||||
session = URLSession(configuration: configuration)
|
||||
filename = "\(modelName).bin"
|
||||
modelURL = cacheDirectory.appending(path: filename)
|
||||
}
|
||||
|
||||
func resume() {
|
||||
if !FileManager.default.fileExists(atPath: cacheDirectory.absoluteURL.path) {
|
||||
try! FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: false)
|
||||
}
|
||||
|
||||
let destination = modelURL.absoluteURL
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
state = .completed
|
||||
return
|
||||
}
|
||||
|
||||
let request = URLRequest(url: baseURL.appending(path: filename))
|
||||
|
||||
let task = session.downloadTask(with: request) { [weak self] location, response, error in
|
||||
if let error = error {
|
||||
self?.state = .failed(error)
|
||||
return
|
||||
}
|
||||
if let location = location {
|
||||
do {
|
||||
try FileManager.default.moveItem(at: location, to: destination)
|
||||
self?.state = .completed
|
||||
} catch {
|
||||
self?.state = .failed(error)
|
||||
}
|
||||
} else {
|
||||
self?.state = .failed(nil)
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
self.task = task
|
||||
}
|
||||
}
|
||||
158
Cheetah/OpenAIExecutor.swift
Normal file
158
Cheetah/OpenAIExecutor.swift
Normal file
@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
|
||||
enum ModelInput {
|
||||
case prompt(String, model: OpenAIModelType.GPT3 = .davinci)
|
||||
case messages([ChatMessage], model: OpenAIModelType.Chat = .gpt4)
|
||||
case chatPrompt(system: String, user: String, model: OpenAIModelType.Chat = .gpt4)
|
||||
}
|
||||
|
||||
class PromptChain<Context> {
|
||||
let generator: (Context) throws -> ModelInput?
|
||||
let updateContext: (String, inout Context) throws -> ()
|
||||
let maxTokens: Int
|
||||
let children: [PromptChain]?
|
||||
|
||||
init(generator: @escaping (Context) throws -> ModelInput?,
|
||||
updateContext: @escaping (String, inout Context) throws -> (),
|
||||
maxTokens: Int = 16,
|
||||
children: [PromptChain]? = nil
|
||||
) {
|
||||
self.generator = generator
|
||||
self.updateContext = updateContext
|
||||
self.maxTokens = maxTokens
|
||||
self.children = children
|
||||
}
|
||||
}
|
||||
|
||||
typealias Prompt = PromptChain
|
||||
|
||||
extension UserDefaults {
|
||||
@objc var logPrompts: Bool {
|
||||
get {
|
||||
bool(forKey: "logPrompts")
|
||||
}
|
||||
set {
|
||||
set(newValue, forKey: "logPrompts")
|
||||
}
|
||||
}
|
||||
|
||||
@objc var logCompletions: Bool {
|
||||
get {
|
||||
bool(forKey: "logCompletions")
|
||||
}
|
||||
set {
|
||||
set(newValue, forKey: "logCompletions")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OpenAIExecutor {
|
||||
let openAI: OpenAISwift
|
||||
let useGPT4: Bool
|
||||
|
||||
init(openAI: OpenAISwift, useGPT4: Bool) {
|
||||
self.openAI = openAI
|
||||
self.useGPT4 = useGPT4
|
||||
}
|
||||
|
||||
convenience init(authToken: String, useGPT4: Bool) {
|
||||
self.init(openAI: .init(authToken: authToken), useGPT4: useGPT4)
|
||||
}
|
||||
|
||||
func log(prompt: String) {
|
||||
if UserDefaults.standard.logPrompts {
|
||||
print("Prompt:\n", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func log(completion: String) {
|
||||
if UserDefaults.standard.logCompletions {
|
||||
print("Completion:\n", completion)
|
||||
}
|
||||
}
|
||||
|
||||
func execute(prompt: String, model: OpenAIModelType, maxTokens: Int = 100) async throws -> String? {
|
||||
log(prompt: prompt)
|
||||
let result = try await openAI.sendCompletion(with: prompt, model: model, maxTokens: maxTokens)
|
||||
let text = result.choices?.first?.text
|
||||
if let text = text {
|
||||
log(completion: text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func execute(messages: [ChatMessage], model: OpenAIModelType, maxTokens: Int = 100) async throws -> String? {
|
||||
log(prompt: messages.debugDescription)
|
||||
let result = try await openAI.sendChat(with: messages, model: model, maxTokens: maxTokens)
|
||||
let content = result.choices?.first?.message.content
|
||||
if let content = content {
|
||||
log(completion: content)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func adjustModel(_ model: OpenAIModelType.Chat) -> OpenAIModelType.Chat {
|
||||
if !useGPT4 && model == .gpt4 {
|
||||
return .chatgpt
|
||||
} else {
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
func execute<K>(chain: PromptChain<[K: String]>, context initialContext: [K: String]) async throws -> [K: String] {
|
||||
var context = initialContext
|
||||
|
||||
guard let input = try chain.generator(context) else {
|
||||
return context
|
||||
}
|
||||
|
||||
let output: String?
|
||||
switch input {
|
||||
case .prompt(let prompt, let model):
|
||||
output = try await execute(prompt: prompt, model: .gpt3(model), maxTokens: chain.maxTokens)
|
||||
|
||||
case .messages(let messages, let model):
|
||||
output = try await execute(messages: messages, model: .chat(adjustModel(model)), maxTokens: chain.maxTokens)
|
||||
|
||||
case .chatPrompt(system: let systemMessage, user: let userMessage, model: let model):
|
||||
let messages = [
|
||||
ChatMessage(role: .system, content: systemMessage),
|
||||
ChatMessage(role: .user, content: userMessage),
|
||||
]
|
||||
output = try await execute(messages: messages, model: .chat(adjustModel(model)), maxTokens: chain.maxTokens)
|
||||
}
|
||||
|
||||
guard let output = output else {
|
||||
return context
|
||||
}
|
||||
|
||||
try chain.updateContext(String(output.trimmingCharacters(in: .whitespacesAndNewlines)), &context)
|
||||
|
||||
let childContext = context
|
||||
|
||||
if let children = chain.children {
|
||||
let childOutputs = try await withThrowingTaskGroup(
|
||||
of: [K: String?].self,
|
||||
returning: [K: String?].self
|
||||
) { group in
|
||||
for child in children {
|
||||
group.addTask {
|
||||
return try await self.execute(chain: child, context: childContext)
|
||||
}
|
||||
}
|
||||
|
||||
return try await group.reduce(into: [:]) {
|
||||
for (key, output) in $1 {
|
||||
$0[key] = output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (key, output) in childOutputs {
|
||||
context[key] = output
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
162
Cheetah/PromptGenerator.swift
Normal file
162
Cheetah/PromptGenerator.swift
Normal file
@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
|
||||
let shorthandInstruction = """
|
||||
Use bullet points and write in shorthand. For example, "O(n log n) due to sorting" is preferred to "The time complexity of the implementation is O(n log n) due to the sorting."
|
||||
"""
|
||||
|
||||
class PromptGenerator {
|
||||
var domain = "software engineering"
|
||||
|
||||
var systemMessage: String {
|
||||
return "You are a \(domain) expert."
|
||||
}
|
||||
|
||||
func extractQuestion(transcript: String) -> ModelInput {
|
||||
let prompt = """
|
||||
Extract the last problem or question posed by the interviewer during a \(domain) interview. State it as an instruction. If the question is about something the candidate did, restate it in a general way.
|
||||
|
||||
[transcript begins]
|
||||
If you want to improve the query performance of multiple columns or a group of columns in a given table. Cool. And is it considered a cluster index or no cluster index? definitely be a non-clustered index. For sure. All right, great. So next question. What's the difference between "where" and "having"? Oh, that's an interesting one.
|
||||
[transcript ends]
|
||||
Is context needed here: Yes
|
||||
Context: queries, databases, performance
|
||||
Extracted question: Describe the difference between "where" and "having" clauses in SQL, focusing on performance.
|
||||
Answer in code: No
|
||||
|
||||
[transcript begins]
|
||||
Are you familiar with the traceroute command? Yes I am. Okay, so how does that work behind the scenes?
|
||||
[transcript ends]
|
||||
Is context needed here: No
|
||||
Extracted question: How does the traceroute command work?
|
||||
Answer in code: No
|
||||
|
||||
[transcript begins]
|
||||
Write a function that takes 3 arguments. The first argument is a list of numbers that is guaranteed to be sorted. The remaining two arguments, a and b, are the coefficients of the function f(x) = a*x + b. Your function should compute f(x) for every number in the first argument, and return a list of those values, also sorted.
|
||||
[transcript ends]
|
||||
Is context needed here: Yes
|
||||
Context: C++
|
||||
Extracted question: C++ function that takes a vector of sorted numbers; and coefficients (a, b) of the function f(x) = a*x + b. It should compute f(x) for each input number, and return a sorted vector.
|
||||
Answer in code: Yes
|
||||
|
||||
[transcript begins]
|
||||
\(transcript)
|
||||
[transcript ends]
|
||||
Is context needed here:
|
||||
"""
|
||||
|
||||
return .chatPrompt(system: systemMessage, user: prompt, model: .chatgpt)
|
||||
}
|
||||
|
||||
func answerQuestion(_ question: String) -> ModelInput {
|
||||
let prompt = """
|
||||
You are a \(domain) expert. \(shorthandInstruction)
|
||||
|
||||
Example 1:
|
||||
Question: Should I use "where" or "having" to find employee first names that appear more than 250 times?
|
||||
Are follow up questions needed here: Yes
|
||||
Follow up: Will this query use aggregation?
|
||||
Intermediate answer: Yes, count(first_name)
|
||||
Follow up: Does "where" or "having" filter rows after aggregation?
|
||||
Intermediate answer: having
|
||||
Final answer:
|
||||
• Where: filters rows before aggregation
|
||||
• Having: filters rows after aggregation
|
||||
• Example SQL: having count(first_name) > 250
|
||||
|
||||
Example 2:
|
||||
Question: How does the traceroute command work?
|
||||
Are follow up questions needed here: No
|
||||
Final answer:
|
||||
• Traces the path an IP packet takes across networks
|
||||
• Starting from 1, increments the TTL field in the IP header
|
||||
• The returned ICMP Time Exceeded packets are used to build a list of routers
|
||||
|
||||
Question: \(question)
|
||||
"""
|
||||
|
||||
return .chatPrompt(system: systemMessage, user: prompt)
|
||||
}
|
||||
|
||||
func answerQuestion(_ question: String, previousAnswer: String) -> ModelInput {
|
||||
let prompt = """
|
||||
You are a \(domain) expert. Refine the partial answer. \(shorthandInstruction)
|
||||
|
||||
Example 1:
|
||||
Question: Should I use "where" or "having" to find employee first names that appear more than 250 times?
|
||||
Partial answer:
|
||||
• Having: filters rows after aggregation
|
||||
Are follow up questions needed here: Yes
|
||||
Follow up: Will this query use aggregation?
|
||||
Intermediate answer: Yes, count(first_name)
|
||||
Follow up: Does "where" or "having" filter rows after aggregation?
|
||||
Intermediate answer: having
|
||||
Final answer:
|
||||
• Where: filters rows before aggregation
|
||||
• Having: filters rows after aggregation
|
||||
• Example SQL: having count(first_name) > 250
|
||||
|
||||
Example 2:
|
||||
Question: How does the traceroute command work?
|
||||
Partial answer:
|
||||
• Traces the path an IP packet takes across networks
|
||||
• Starting from 1, increments the TTL field in the IP header
|
||||
Are follow up questions needed here: No
|
||||
Final answer:
|
||||
• Traces the path an IP packet takes across networks
|
||||
• Starting from 1, increments the TTL field in the IP header
|
||||
• The returned ICMP Time Exceeded packets are used to build a list of routers
|
||||
|
||||
Question: \(question)
|
||||
Partial answer:
|
||||
\(previousAnswer)
|
||||
"""
|
||||
|
||||
return .chatPrompt(system: systemMessage, user: prompt)
|
||||
}
|
||||
|
||||
func answerQuestion(_ question: String, highlightedAnswer: String) -> ModelInput {
|
||||
let prompt = """
|
||||
Question: \(question)
|
||||
|
||||
You previously provided this answer, and I have highlighted part of it:
|
||||
\(highlightedAnswer)
|
||||
|
||||
Explain the highlighted part of your previous answer in much greater depth. \(shorthandInstruction)
|
||||
"""
|
||||
|
||||
return .chatPrompt(system: systemMessage, user: prompt)
|
||||
}
|
||||
|
||||
func writeCode(task: String) -> ModelInput {
|
||||
let prompt = """
|
||||
Write pseudocode to accomplish this task: \(task)
|
||||
|
||||
Start with a comment outlining opportunities for optimization and potential pitfalls. Assume only standard libraries are available, unless specified. Don't explain, just give me the code.
|
||||
"""
|
||||
|
||||
return .chatPrompt(system: systemMessage, user: prompt)
|
||||
}
|
||||
|
||||
func analyzeBrowserCode(_ code: String, logs: String, task: String? = nil) -> ModelInput {
|
||||
let prefix: String
|
||||
if let task = task {
|
||||
prefix = "Prompt: \(task)"
|
||||
} else {
|
||||
prefix = "Briefly describe how an efficient solution can be achieved."
|
||||
}
|
||||
|
||||
let prompt = """
|
||||
\(prefix)
|
||||
|
||||
Code:
|
||||
\(code)
|
||||
|
||||
Output:
|
||||
\(logs)
|
||||
|
||||
If the prompt is irrelevant, you may disregard it. You may suggest edits to the existing code. If appropriate, include a brief discussion of complexity. \(shorthandInstruction)
|
||||
"""
|
||||
|
||||
return .chatPrompt(system: systemMessage, user: prompt)
|
||||
}
|
||||
}
|
||||
39
Cheetah/Views/AuthTokenView.swift
Normal file
39
Cheetah/Views/AuthTokenView.swift
Normal file
@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
import LibWhisper
|
||||
|
||||
struct AuthTokenView: View {
|
||||
@Binding var storedToken: String?
|
||||
@Binding var useGPT4: Bool
|
||||
|
||||
@State var tokenValue = ""
|
||||
@State var toggleValue = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Link(destination: URL(string: "https://platform.openai.com/account/api-keys")!) {
|
||||
Text("Click here to create an OpenAI API key")
|
||||
}
|
||||
TextField(text: $tokenValue) {
|
||||
Text("Paste your API key here")
|
||||
}
|
||||
.privacySensitive()
|
||||
.frame(width: 300)
|
||||
Toggle("Use GPT-4 (access required)", isOn: $toggleValue)
|
||||
Button("Save") {
|
||||
storedToken = tokenValue
|
||||
useGPT4 = toggleValue
|
||||
}
|
||||
.disabled(tokenValue.isEmpty)
|
||||
}
|
||||
.padding()
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
struct APIKeyView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
return AuthTokenView(
|
||||
storedToken: Binding.constant(nil),
|
||||
useGPT4: Binding.constant(false))
|
||||
}
|
||||
}
|
||||
86
Cheetah/Views/CoachView.swift
Normal file
86
Cheetah/Views/CoachView.swift
Normal file
@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import LibWhisper
|
||||
|
||||
struct CoachView: View {
|
||||
@ObservedObject var viewModel: AppViewModel
|
||||
|
||||
@State var answer: String
|
||||
@State var answerSelection = NSRange()
|
||||
|
||||
init(viewModel: AppViewModel) {
|
||||
self.viewModel = viewModel
|
||||
self.answer = viewModel.answer ?? ""
|
||||
}
|
||||
|
||||
var spinner: some View {
|
||||
ProgressView().scaleEffect(0.5)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
Picker("Audio input device", selection: $viewModel.selectedDevice) {
|
||||
Text("-").tag(nil as CaptureDevice?)
|
||||
ForEach(viewModel.devices, id: \.self) {
|
||||
Text($0.name).tag($0 as CaptureDevice?)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
ZStack {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: {
|
||||
viewModel.answerRequest = .answerQuestion
|
||||
}, label: {
|
||||
Text("Answer")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.answerRequest = .refineAnswer(selection: Range(answerSelection, in: answer))
|
||||
}, label: {
|
||||
Text("Refine")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.answerRequest = .analyzeCode
|
||||
}, label: {
|
||||
Text("Analyze")
|
||||
})
|
||||
}
|
||||
.disabled((viewModel.authToken == nil || viewModel.analyzer == nil) && !viewModel.buttonsAlwaysEnabled)
|
||||
HStack {
|
||||
Spacer()
|
||||
switch viewModel.answerRequest {
|
||||
case .none:
|
||||
spinner.hidden()
|
||||
default:
|
||||
spinner
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if let transcript = viewModel.transcript {
|
||||
Text(transcript)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.font(.footnote.italic())
|
||||
}
|
||||
ScrollView {
|
||||
NSTextFieldWrapper(text: $answer, selectedRange: $answerSelection)
|
||||
.onChange(of: viewModel.answer) {
|
||||
if let newAnswer = $0 {
|
||||
self.answer = newAnswer
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 600)
|
||||
if let solution = viewModel.codeAnswer {
|
||||
Text(solution)
|
||||
.textSelection(.enabled)
|
||||
.font(.footnote)
|
||||
.monospaced()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
68
Cheetah/Views/ContentView.swift
Normal file
68
Cheetah/Views/ContentView.swift
Normal file
@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
import LibWhisper
|
||||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var viewModel: AppViewModel
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if viewModel.authToken != nil {
|
||||
VStack(spacing: 16) {
|
||||
switch viewModel.downloadState {
|
||||
case .pending:
|
||||
Text("Downloading \(viewModel.whisperModel)...")
|
||||
|
||||
case .failed(let error):
|
||||
if let error = error {
|
||||
Text("Failed to download model. \(error.localizedDescription)")
|
||||
} else {
|
||||
Text("Failed to download model. An unknown error occurred.")
|
||||
}
|
||||
|
||||
case .completed:
|
||||
CoachView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 300, minHeight: 350)
|
||||
} else {
|
||||
AuthTokenView(storedToken: viewModel.$authToken,
|
||||
useGPT4: viewModel.$useGPT4.nonEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let viewModel = AppViewModel()
|
||||
viewModel.devices = [CaptureDevice(id: 0, name: "Audio Loopback Device")]
|
||||
viewModel.buttonsAlwaysEnabled = true
|
||||
viewModel.authToken = ""
|
||||
viewModel.downloadState = .completed
|
||||
viewModel.transcript = "So how would we break this app down into components?"
|
||||
viewModel.answer = """
|
||||
• Header Component: Contains two sub-components: Logo and Title.
|
||||
Props: logoUrl, title
|
||||
|
||||
• Content Component: Contains an image and a paragraph.
|
||||
Props: imageUrl, message
|
||||
|
||||
• Footer Component: Simple component that displays a message.
|
||||
Props: message
|
||||
|
||||
• App Component: Renders the Header, Content, and Footer components
|
||||
"""
|
||||
return ContentView(viewModel: viewModel)
|
||||
.previewLayout(.fixed(width: 300, height: 500))
|
||||
.previewDisplayName("Cheetah")
|
||||
}
|
||||
}
|
||||
|
||||
extension Binding where Value == Bool? {
|
||||
var nonEmpty: Binding<Bool> {
|
||||
Binding<Bool>(
|
||||
get: { self.wrappedValue ?? false },
|
||||
set: { self.wrappedValue = $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
53
Cheetah/Views/NSTextFieldWrapper.swift
Normal file
53
Cheetah/Views/NSTextFieldWrapper.swift
Normal file
@ -0,0 +1,53 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
class CustomNSTextField: RSHeightHuggingTextField {
|
||||
var onSelectedRangesChanged: ((NSRange?) -> Void)?
|
||||
|
||||
@objc func textViewDidChangeSelection(_ notification: NSNotification) {
|
||||
onSelectedRangesChanged?(currentEditor()?.selectedRange)
|
||||
}
|
||||
}
|
||||
|
||||
struct NSTextFieldWrapper: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var selectedRange: NSRange
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> CustomNSTextField {
|
||||
let textField = CustomNSTextField(frame: NSRect())
|
||||
textField.isBezeled = false
|
||||
textField.isEditable = false
|
||||
textField.isSelectable = true
|
||||
textField.drawsBackground = false
|
||||
textField.delegate = context.coordinator
|
||||
textField.onSelectedRangesChanged = { range in
|
||||
if let range = range {
|
||||
DispatchQueue.main.async {
|
||||
self.selectedRange = range
|
||||
}
|
||||
}
|
||||
}
|
||||
return textField
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: CustomNSTextField, context: Context) {
|
||||
nsView.stringValue = text
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSTextFieldDelegate {
|
||||
var parent: NSTextFieldWrapper
|
||||
|
||||
init(_ textField: NSTextFieldWrapper) {
|
||||
self.parent = textField
|
||||
}
|
||||
|
||||
func controlTextDidChange(_ obj: Notification) {
|
||||
guard let textField = obj.object as? NSTextField else { return }
|
||||
self.parent.text = textField.stringValue
|
||||
}
|
||||
}
|
||||
}
|
||||
106
Cheetah/Views/RSDimensionHuggingTextField.swift
Normal file
106
Cheetah/Views/RSDimensionHuggingTextField.swift
Normal file
@ -0,0 +1,106 @@
|
||||
//
|
||||
// RSDimensionHuggingTextField.swift
|
||||
// RSUIKit
|
||||
//
|
||||
// Created by Daniel Jalkut on 6/13/18.
|
||||
// Copyright © 2018 Red Sweater. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
// You probably want to use one of RSHeightHuggingTextField or RSWidthHuggingTextField, below
|
||||
|
||||
open class RSDimensionHuggingTextField: NSTextField {
|
||||
|
||||
public enum Dimension {
|
||||
case vertical
|
||||
case horizontal
|
||||
}
|
||||
|
||||
var huggedDimension: Dimension
|
||||
|
||||
init(frame frameRect: NSRect, huggedDimension: Dimension) {
|
||||
self.huggedDimension = huggedDimension
|
||||
super.init(frame: frameRect)
|
||||
}
|
||||
|
||||
// For subclasses to pass in the dimension setting
|
||||
public init?(coder: NSCoder, huggedDimension: Dimension) {
|
||||
self.huggedDimension = huggedDimension
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
// We don't yet support dimension being coded, just default to vertical
|
||||
self.huggedDimension = .vertical
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
open override var intrinsicContentSize: NSSize {
|
||||
get {
|
||||
guard let textCell = self.cell else {
|
||||
return super.intrinsicContentSize
|
||||
}
|
||||
|
||||
// Set up the bounds to induce unlimited sizing in the desired dimension
|
||||
var cellSizeBounds = self.bounds
|
||||
switch self.huggedDimension {
|
||||
case .vertical: cellSizeBounds.size.height = CGFloat(Float.greatestFiniteMagnitude)
|
||||
case .horizontal: cellSizeBounds.size.width = CGFloat(Float.greatestFiniteMagnitude)
|
||||
}
|
||||
|
||||
// Do the actual sizing
|
||||
let nativeCellSize = textCell.cellSize(forBounds: cellSizeBounds)
|
||||
|
||||
// Return an intrinsic size that imposes calculated (hugged) dimensional size
|
||||
var intrinsicSize = NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
|
||||
switch self.huggedDimension {
|
||||
case .vertical:
|
||||
intrinsicSize.height = nativeCellSize.height
|
||||
case .horizontal:
|
||||
intrinsicSize.width = nativeCellSize.width
|
||||
}
|
||||
return intrinsicSize
|
||||
}
|
||||
}
|
||||
|
||||
open override func textDidChange(_ notification: Notification) {
|
||||
super.textDidChange(notification)
|
||||
self.invalidateIntrinsicContentSize()
|
||||
|
||||
// It seems important to set the string from the cell on ourself to
|
||||
// get the change to be respected by the cell and to get the cellSize
|
||||
// computation to update!
|
||||
if let changedCell = self.cell {
|
||||
self.stringValue = changedCell.stringValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open class RSHeightHuggingTextField: RSDimensionHuggingTextField {
|
||||
@objc init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect, huggedDimension: .vertical)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
super.init(coder: coder, huggedDimension: .vertical)
|
||||
}
|
||||
|
||||
public override init(frame frameRect: NSRect, huggedDimension: Dimension = .vertical) {
|
||||
super.init(frame: frameRect, huggedDimension: huggedDimension)
|
||||
}
|
||||
}
|
||||
|
||||
open class RSWidthHuggingTextField: RSDimensionHuggingTextField {
|
||||
@objc init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect, huggedDimension: .horizontal)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
super.init(coder: coder, huggedDimension: .horizontal)
|
||||
}
|
||||
|
||||
public override init(frame frameRect: NSRect, huggedDimension: Dimension = .horizontal) {
|
||||
super.init(frame: frameRect, huggedDimension: huggedDimension)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user