initial commit
This commit is contained in:
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