initial commit

This commit is contained in:
Sam
2023-03-26 17:31:42 -04:00
commit e3b5b090fb
51 changed files with 4222 additions and 0 deletions

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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)
}

View 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
View 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))
}
}

View 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
View 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>

View 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
}
}

View 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
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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)
}
}

View 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))
}
}

View 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()
}
}
}

View 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 }
)
}
}

View 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
}
}
}

View 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)
}
}