initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
xcuserdata/
|
||||
19
.vscode/c_cpp_properties.json
vendored
Normal file
19
.vscode/c_cpp_properties.json
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Mac",
|
||||
"includePath": [
|
||||
"${workspaceFolder}/**"
|
||||
],
|
||||
"defines": [],
|
||||
"macFrameworkPath": [
|
||||
"/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks"
|
||||
],
|
||||
"compilerPath": "/usr/bin/clang",
|
||||
"cStandard": "c17",
|
||||
"cppStandard": "c++17",
|
||||
"intelliSenseMode": "macos-clang-arm64"
|
||||
}
|
||||
],
|
||||
"version": 4
|
||||
}
|
||||
1035
Cheetah.xcodeproj/project.pbxproj
Normal file
1035
Cheetah.xcodeproj/project.pbxproj
Normal file
File diff suppressed because it is too large
Load Diff
7
Cheetah.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Cheetah.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@ -0,0 +1,8 @@
|
||||
<?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>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
78
Cheetah.xcodeproj/xcshareddata/xcschemes/Cheetah.xcscheme
Normal file
78
Cheetah.xcodeproj/xcshareddata/xcschemes/Cheetah.xcscheme
Normal file
@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1420"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "37AE7A6E29A5A8B300C45FF6"
|
||||
BuildableName = "Cheetah.app"
|
||||
BlueprintName = "Cheetah"
|
||||
ReferencedContainer = "container:Cheetah.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "37AE7A6E29A5A8B300C45FF6"
|
||||
BuildableName = "Cheetah.app"
|
||||
BlueprintName = "Cheetah"
|
||||
ReferencedContainer = "container:Cheetah.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "37AE7A6E29A5A8B300C45FF6"
|
||||
BuildableName = "Cheetah.app"
|
||||
BlueprintName = "Cheetah"
|
||||
ReferencedContainer = "container:Cheetah.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
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)
|
||||
}
|
||||
}
|
||||
10
CheetahIPC/CheetahIPC.h
Normal file
10
CheetahIPC/CheetahIPC.h
Normal file
@ -0,0 +1,10 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
//! Project version number for CheetahIPC.
|
||||
FOUNDATION_EXPORT double CheetahIPCVersionNumber;
|
||||
|
||||
//! Project version string for CheetahIPC.
|
||||
FOUNDATION_EXPORT const unsigned char CheetahIPCVersionString[];
|
||||
|
||||
// In this header, you should import all the public headers of your framework using statements like #import <CheetahIPC/PublicHeader.h>
|
||||
|
||||
67
CheetahIPC/Client.swift
Normal file
67
CheetahIPC/Client.swift
Normal file
@ -0,0 +1,67 @@
|
||||
import CoreFoundation
|
||||
import UserNotifications
|
||||
|
||||
public enum IPCClientError: Error {
|
||||
case createRemoteFailure
|
||||
case sendRequestFailure(Int32)
|
||||
}
|
||||
|
||||
public class IPCClient {
|
||||
let remote: CFMessagePort
|
||||
|
||||
public init(messagePortName: String) throws {
|
||||
if let remote = CFMessagePortCreateRemote(nil, messagePortName as CFString) {
|
||||
self.remote = remote
|
||||
} else {
|
||||
throw IPCClientError.createRemoteFailure
|
||||
}
|
||||
}
|
||||
|
||||
public func sendRequest(msgid: Int32, data: Data) throws -> Data? {
|
||||
var responseData: Unmanaged<CFData>? = nil
|
||||
|
||||
let result = CFMessagePortSendRequest(
|
||||
remote,
|
||||
msgid,
|
||||
data as CFData,
|
||||
1.0, // sendTimeout
|
||||
1.0, // rcvTimeout
|
||||
CFRunLoopMode.defaultMode.rawValue,
|
||||
&responseData)
|
||||
|
||||
if result == kCFMessagePortSuccess {
|
||||
return responseData?.takeRetainedValue() as Data?
|
||||
} else {
|
||||
throw IPCClientError.sendRequestFailure(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension IPCClient {
|
||||
func encode(_ object: Encodable) throws -> Data {
|
||||
return try PropertyListEncoder().encode(object)
|
||||
}
|
||||
|
||||
func encode(_ object: NSCoding) throws -> Data {
|
||||
return try NSKeyedArchiver.archivedData(withRootObject: object, requiringSecureCoding: true)
|
||||
}
|
||||
|
||||
func decode<T: Decodable>(_ data: Data) throws -> T {
|
||||
return try PropertyListDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
func decode<T>(_ data: Data) throws -> T? where T: NSObject, T: NSCoding {
|
||||
return try NSKeyedUnarchiver.unarchivedObject(ofClass: T.self, from: data)
|
||||
}
|
||||
|
||||
func sendMessage(id: any RawRepresentable<Int32>, withObject object: NSCoding) throws {
|
||||
_ = try sendRequest(msgid: id.rawValue, data: encode(object))
|
||||
}
|
||||
|
||||
func sendMessage<T: Decodable>(id: any RawRepresentable<Int32>, withObject object: NSCoding) throws -> T? {
|
||||
guard let resultData = try sendRequest(msgid: id.rawValue, data: encode(object)) else {
|
||||
return nil
|
||||
}
|
||||
return try decode(resultData)
|
||||
}
|
||||
}
|
||||
17
CheetahIPC/Messages.swift
Normal file
17
CheetahIPC/Messages.swift
Normal file
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public enum MessagePortName: String {
|
||||
case browserExtensionServer = "org.phrack.Cheetah.BrowserExtensionServer"
|
||||
}
|
||||
|
||||
public enum IPCMessage: Int32 {
|
||||
case browserExtensionMessage = 1
|
||||
}
|
||||
|
||||
public struct BrowserExtensionMessage: Codable {
|
||||
public var mode: String
|
||||
public var files = [String: String]()
|
||||
public var logs = [String: String]()
|
||||
public var navigationStart: Int
|
||||
}
|
||||
95
CheetahIPC/Server.swift
Normal file
95
CheetahIPC/Server.swift
Normal file
@ -0,0 +1,95 @@
|
||||
import CoreFoundation
|
||||
|
||||
func serverCallback(local: CFMessagePort?, msgid: Int32, data: CFData?, info: UnsafeMutableRawPointer?) -> Unmanaged<CFData>? {
|
||||
let server = Unmanaged<IPCServer>.fromOpaque(info!).takeUnretainedValue()
|
||||
let responseData = server.delegate?.handleMessageWithID(msgid, data: data! as Data)
|
||||
if let responseData = responseData as? NSData,
|
||||
let cfdata = CFDataCreate(nil, responseData.bytes, responseData.length) {
|
||||
return Unmanaged.passRetained(cfdata)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public protocol IPCServerDelegate: AnyObject {
|
||||
func handleMessageWithID(_ msgid: Int32, data: Data) -> Data?
|
||||
}
|
||||
|
||||
open class NSCodingHandler<RequestObject>: NSObject, IPCServerDelegate {
|
||||
public typealias Handler = (RequestObject?) -> Encodable?
|
||||
|
||||
public let messageID: Int32
|
||||
public var handler: Handler?
|
||||
|
||||
public init(respondsTo id: any RawRepresentable<Int32>, _ handler: Handler? = nil) {
|
||||
self.messageID = id.rawValue
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
public func handleMessageWithID(_ msgid: Int32, data: Data) -> Data? {
|
||||
let object = NSKeyedUnarchiver.unarchiveObject(with: data) as? RequestObject
|
||||
if let handler = handler, let result = handler(object) {
|
||||
return try? NSKeyedArchiver.archivedData(withRootObject: result, requiringSecureCoding: false)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open class JSONHandler<RequestObject: Decodable>: IPCServerDelegate {
|
||||
public typealias Handler = (RequestObject?) -> Encodable?
|
||||
|
||||
public let messageID: Int32
|
||||
public var handler: Handler?
|
||||
|
||||
public init(respondsTo id: any RawRepresentable<Int32>, _ handler: Handler? = nil) {
|
||||
self.messageID = id.rawValue
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
public func handleMessageWithID(_ msgid: Int32, data: Data) -> Data? {
|
||||
let object = try? JSONDecoder().decode(RequestObject.self, from: data)
|
||||
if let object = object, let handler = handler, let result = handler(object) {
|
||||
return try? NSKeyedArchiver.archivedData(withRootObject: result, requiringSecureCoding: false)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class IPCServer: NSObject {
|
||||
public weak var delegate: IPCServerDelegate?
|
||||
|
||||
/// Create the local message port then register an input source for it
|
||||
public func addSourceForNewLocalMessagePort(name: String, toRunLoop runLoop: CFRunLoop!) {
|
||||
if let messagePort = createMessagePort(name: name) {
|
||||
addSource(messagePort: messagePort, toRunLoop: runLoop)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a local message port with the specified name
|
||||
///
|
||||
/// Incoming messages will be routed to this object's handleMessageWithID(,data:) method.
|
||||
func createMessagePort(name: String) -> CFMessagePort? {
|
||||
var context = CFMessagePortContext(
|
||||
version: 0,
|
||||
info: Unmanaged.passUnretained(self).toOpaque(),
|
||||
retain: nil,
|
||||
release: nil,
|
||||
copyDescription: nil)
|
||||
var shouldFreeInfo: DarwinBoolean = false
|
||||
|
||||
return CFMessagePortCreateLocal(
|
||||
nil,
|
||||
name as CFString,
|
||||
serverCallback,
|
||||
&context,
|
||||
&shouldFreeInfo)
|
||||
}
|
||||
|
||||
/// Create an input source for the specified message port and add it to the specified run loop
|
||||
func addSource(messagePort: CFMessagePort, toRunLoop runLoop: CFRunLoop) {
|
||||
let source = CFMessagePortCreateRunLoopSource(nil, messagePort, 0)
|
||||
CFRunLoopAddSource(runLoop, source, CFRunLoopMode.commonModes)
|
||||
}
|
||||
}
|
||||
5
ExtensionHelper/ExtensionHelper.entitlements
Normal file
5
ExtensionHelper/ExtensionHelper.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>
|
||||
78
ExtensionHelper/main.swift
Normal file
78
ExtensionHelper/main.swift
Normal file
@ -0,0 +1,78 @@
|
||||
// ExtensionHelper is invoked by the browser extension and killed automatically
|
||||
// when all tabs using the extension are closed. It relays incoming messages to
|
||||
// the IPCServer running in the main app.
|
||||
|
||||
import Cocoa
|
||||
import CheetahIPC
|
||||
|
||||
let client = try? IPCClient(messagePortName: MessagePortName.browserExtensionServer.rawValue)
|
||||
guard let client = client else {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
let stdin = FileHandle.standardInput
|
||||
_ = enableRawMode(fileHandle: stdin)
|
||||
|
||||
func handleMessage() throws {
|
||||
// Read length
|
||||
guard let size = stdin.readData(ofLength: 4).value(ofType: UInt32.self, at: 0, convertEndian: true) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Read message
|
||||
let data = stdin.readData(ofLength: Int(size))
|
||||
_ = try client.sendRequest(msgid: IPCMessage.browserExtensionMessage.rawValue, data: data)
|
||||
|
||||
//TODO: send response (UInt32 length followed by JSON message)
|
||||
}
|
||||
|
||||
while true {
|
||||
do {
|
||||
try handleMessage()
|
||||
} catch {
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// https://forums.swift.org/t/how-to-read-uint32-from-a-data/59431/11
|
||||
extension Data {
|
||||
subscript<T: BinaryInteger>(at offset: Int, convertEndian convertEndian: Bool = false) -> T? {
|
||||
value(ofType: T.self, at: offset, convertEndian: convertEndian)
|
||||
}
|
||||
|
||||
func value<T: BinaryInteger>(ofType: T.Type, at offset: Int, convertEndian: Bool = false) -> T? {
|
||||
let right = offset &+ MemoryLayout<T>.size
|
||||
guard offset >= 0 && right > offset && right <= count else {
|
||||
return nil
|
||||
}
|
||||
let bytes = self[offset ..< right]
|
||||
if convertEndian {
|
||||
return bytes.reversed().reduce(0) { T($0) << 8 + T($1) }
|
||||
} else {
|
||||
return bytes.reduce(0) { T($0) << 8 + T($1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// see https://stackoverflow.com/a/24335355/669586
|
||||
func initStruct<S>() -> S {
|
||||
let struct_pointer = UnsafeMutablePointer<S>.allocate(capacity: 1)
|
||||
let struct_memory = struct_pointer.pointee
|
||||
struct_pointer.deallocate()
|
||||
return struct_memory
|
||||
}
|
||||
|
||||
func enableRawMode(fileHandle: FileHandle) -> termios {
|
||||
var raw: termios = initStruct()
|
||||
tcgetattr(fileHandle.fileDescriptor, &raw)
|
||||
|
||||
let original = raw
|
||||
|
||||
raw.c_lflag &= ~(UInt(ECHO | ICANON))
|
||||
tcsetattr(fileHandle.fileDescriptor, TCSAFLUSH, &raw);
|
||||
|
||||
return original
|
||||
}
|
||||
116
LICENSE
Normal file
116
LICENSE
Normal file
@ -0,0 +1,116 @@
|
||||
CC0 1.0 Universal
|
||||
|
||||
Statement of Purpose
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer
|
||||
exclusive Copyright and Related Rights (defined below) upon the creator and
|
||||
subsequent owner(s) (each and all, an "owner") of an original work of
|
||||
authorship and/or a database (each, a "Work").
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the
|
||||
purpose of contributing to a commons of creative, cultural and scientific
|
||||
works ("Commons") that the public can reliably and without fear of later
|
||||
claims of infringement build upon, modify, incorporate in other works, reuse
|
||||
and redistribute as freely as possible in any form whatsoever and for any
|
||||
purposes, including without limitation commercial purposes. These owners may
|
||||
contribute to the Commons to promote the ideal of a free culture and the
|
||||
further production of creative, cultural and scientific works, or to gain
|
||||
reputation or greater distribution for their Work in part through the use and
|
||||
efforts of others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation
|
||||
of additional consideration or compensation, the person associating CC0 with a
|
||||
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
|
||||
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
|
||||
and publicly distribute the Work under its terms, with knowledge of his or her
|
||||
Copyright and Related Rights in the Work and the meaning and intended legal
|
||||
effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||
protected by copyright and related or neighboring rights ("Copyright and
|
||||
Related Rights"). Copyright and Related Rights include, but are not limited
|
||||
to, the following:
|
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display, communicate,
|
||||
and translate a Work;
|
||||
|
||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||
|
||||
iii. publicity and privacy rights pertaining to a person's image or likeness
|
||||
depicted in a Work;
|
||||
|
||||
iv. rights protecting against unfair competition in regards to a Work,
|
||||
subject to the limitations in paragraph 4(a), below;
|
||||
|
||||
v. rights protecting the extraction, dissemination, use and reuse of data in
|
||||
a Work;
|
||||
|
||||
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||
European Parliament and of the Council of 11 March 1996 on the legal
|
||||
protection of databases, and under any national implementation thereof,
|
||||
including any amended or successor version of such directive); and
|
||||
|
||||
vii. other similar, equivalent or corresponding rights throughout the world
|
||||
based on applicable law or treaty, and any national implementations thereof.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention of,
|
||||
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
||||
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
|
||||
and Related Rights and associated claims and causes of action, whether now
|
||||
known or unknown (including existing as well as future claims and causes of
|
||||
action), in the Work (i) in all territories worldwide, (ii) for the maximum
|
||||
duration provided by applicable law or treaty (including future time
|
||||
extensions), (iii) in any current or future medium and for any number of
|
||||
copies, and (iv) for any purpose whatsoever, including without limitation
|
||||
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
|
||||
the Waiver for the benefit of each member of the public at large and to the
|
||||
detriment of Affirmer's heirs and successors, fully intending that such Waiver
|
||||
shall not be subject to revocation, rescission, cancellation, termination, or
|
||||
any other legal or equitable action to disrupt the quiet enjoyment of the Work
|
||||
by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason be
|
||||
judged legally invalid or ineffective under applicable law, then the Waiver
|
||||
shall be preserved to the maximum extent permitted taking into account
|
||||
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
|
||||
is so judged Affirmer hereby grants to each affected person a royalty-free,
|
||||
non transferable, non sublicensable, non exclusive, irrevocable and
|
||||
unconditional license to exercise Affirmer's Copyright and Related Rights in
|
||||
the Work (i) in all territories worldwide, (ii) for the maximum duration
|
||||
provided by applicable law or treaty (including future time extensions), (iii)
|
||||
in any current or future medium and for any number of copies, and (iv) for any
|
||||
purpose whatsoever, including without limitation commercial, advertising or
|
||||
promotional purposes (the "License"). The License shall be deemed effective as
|
||||
of the date CC0 was applied by Affirmer to the Work. Should any part of the
|
||||
License for any reason be judged legally invalid or ineffective under
|
||||
applicable law, such partial invalidity or ineffectiveness shall not
|
||||
invalidate the remainder of the License, and in such case Affirmer hereby
|
||||
affirms that he or she will not (i) exercise any of his or her remaining
|
||||
Copyright and Related Rights in the Work or (ii) assert any associated claims
|
||||
and causes of action with respect to the Work, in either case contrary to
|
||||
Affirmer's express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||
surrendered, licensed or otherwise affected by this document.
|
||||
|
||||
b. Affirmer offers the Work as-is and makes no representations or warranties
|
||||
of any kind concerning the Work, express, implied, statutory or otherwise,
|
||||
including without limitation warranties of title, merchantability, fitness
|
||||
for a particular purpose, non infringement, or the absence of latent or
|
||||
other defects, accuracy, or the present or absence of errors, whether or not
|
||||
discoverable, all to the greatest extent permissible under applicable law.
|
||||
|
||||
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||
that may apply to the Work or any use thereof, including without limitation
|
||||
any person's Copyright and Related Rights in the Work. Further, Affirmer
|
||||
disclaims responsibility for obtaining any necessary consents, permissions
|
||||
or other rights required for any use of the Work.
|
||||
|
||||
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||
party to this document and has no duty or obligation with respect to this
|
||||
CC0 or use of the Work.
|
||||
|
||||
For more information, please see
|
||||
<http://creativecommons.org/publicdomain/zero/1.0/>
|
||||
43
LibWhisper/CaptureDevice.swift
Normal file
43
LibWhisper/CaptureDevice.swift
Normal file
@ -0,0 +1,43 @@
|
||||
public enum CaptureDeviceError: Error {
|
||||
case sdlErrorCode(Int32)
|
||||
}
|
||||
|
||||
public struct CaptureDevice {
|
||||
public let id: Int32
|
||||
public let name: String
|
||||
|
||||
public init(id: Int32, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public static var devices: [CaptureDevice] {
|
||||
get throws {
|
||||
var devices = [CaptureDevice]()
|
||||
|
||||
let result = SDL_Init(SDL_INIT_AUDIO)
|
||||
if result < 0 {
|
||||
throw CaptureDeviceError.sdlErrorCode(result)
|
||||
}
|
||||
|
||||
for i in 0..<SDL_GetNumAudioDevices(1) {
|
||||
let name = String(cString: SDL_GetAudioDeviceName(i, 1))
|
||||
devices.append(CaptureDevice(id: i, name: name))
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CaptureDevice: Equatable {
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension CaptureDevice: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
15
LibWhisper/LibWhisper.h
Normal file
15
LibWhisper/LibWhisper.h
Normal file
@ -0,0 +1,15 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
//! Project version number for LibWhisper.
|
||||
FOUNDATION_EXPORT double LibWhisperVersionNumber;
|
||||
|
||||
//! Project version string for LibWhisper.
|
||||
FOUNDATION_EXPORT const unsigned char LibWhisperVersionString[];
|
||||
|
||||
// SDL functions used in CaptureDevice
|
||||
#define SDL_INIT_AUDIO 0x00000010u
|
||||
extern int SDL_Init(uint32_t flags);
|
||||
extern int SDL_GetNumAudioDevices(int iscapture);
|
||||
extern const char * SDL_GetAudioDeviceName(int index, int iscapture);
|
||||
|
||||
#import "stream.h"
|
||||
233
LibWhisper/SDL.h
Normal file
233
LibWhisper/SDL.h
Normal file
@ -0,0 +1,233 @@
|
||||
/*
|
||||
Simple DirectMedia Layer
|
||||
Copyright (C) 1997-2023 Sam Lantinga <slouken@libsdl.org>
|
||||
|
||||
This software is provided 'as-is', without any express or implied
|
||||
warranty. In no event will the authors be held liable for any damages
|
||||
arising from the use of this software.
|
||||
|
||||
Permission is granted to anyone to use this software for any purpose,
|
||||
including commercial applications, and to alter it and redistribute it
|
||||
freely, subject to the following restrictions:
|
||||
|
||||
1. The origin of this software must not be misrepresented; you must not
|
||||
claim that you wrote the original software. If you use this software
|
||||
in a product, an acknowledgment in the product documentation would be
|
||||
appreciated but is not required.
|
||||
2. Altered source versions must be plainly marked as such, and must not be
|
||||
misrepresented as being the original software.
|
||||
3. This notice may not be removed or altered from any source distribution.
|
||||
*/
|
||||
|
||||
/**
|
||||
* \file SDL.h
|
||||
*
|
||||
* Main include header for the SDL library
|
||||
*/
|
||||
|
||||
|
||||
#ifndef SDL_h_
|
||||
#define SDL_h_
|
||||
|
||||
#include "SDL_main.h"
|
||||
#include "SDL_stdinc.h"
|
||||
#include "SDL_assert.h"
|
||||
#include "SDL_atomic.h"
|
||||
#include "SDL_audio.h"
|
||||
#include "SDL_clipboard.h"
|
||||
#include "SDL_cpuinfo.h"
|
||||
#include "SDL_endian.h"
|
||||
#include "SDL_error.h"
|
||||
#include "SDL_events.h"
|
||||
#include "SDL_filesystem.h"
|
||||
#include "SDL_gamecontroller.h"
|
||||
#include "SDL_guid.h"
|
||||
#include "SDL_haptic.h"
|
||||
#include "SDL_hidapi.h"
|
||||
#include "SDL_hints.h"
|
||||
#include "SDL_joystick.h"
|
||||
#include "SDL_loadso.h"
|
||||
#include "SDL_log.h"
|
||||
#include "SDL_messagebox.h"
|
||||
#include "SDL_metal.h"
|
||||
#include "SDL_mutex.h"
|
||||
#include "SDL_power.h"
|
||||
#include "SDL_render.h"
|
||||
#include "SDL_rwops.h"
|
||||
#include "SDL_sensor.h"
|
||||
#include "SDL_shape.h"
|
||||
#include "SDL_system.h"
|
||||
#include "SDL_thread.h"
|
||||
#include "SDL_timer.h"
|
||||
#include "SDL_version.h"
|
||||
#include "SDL_video.h"
|
||||
#include "SDL_locale.h"
|
||||
#include "SDL_misc.h"
|
||||
|
||||
#include "begin_code.h"
|
||||
/* Set up for C function definitions, even when using C++ */
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* As of version 0.5, SDL is loaded dynamically into the application */
|
||||
|
||||
/**
|
||||
* \name SDL_INIT_*
|
||||
*
|
||||
* These are the flags which may be passed to SDL_Init(). You should
|
||||
* specify the subsystems which you will be using in your application.
|
||||
*/
|
||||
/* @{ */
|
||||
#define SDL_INIT_TIMER 0x00000001u
|
||||
#define SDL_INIT_AUDIO 0x00000010u
|
||||
#define SDL_INIT_VIDEO 0x00000020u /**< SDL_INIT_VIDEO implies SDL_INIT_EVENTS */
|
||||
#define SDL_INIT_JOYSTICK 0x00000200u /**< SDL_INIT_JOYSTICK implies SDL_INIT_EVENTS */
|
||||
#define SDL_INIT_HAPTIC 0x00001000u
|
||||
#define SDL_INIT_GAMECONTROLLER 0x00002000u /**< SDL_INIT_GAMECONTROLLER implies SDL_INIT_JOYSTICK */
|
||||
#define SDL_INIT_EVENTS 0x00004000u
|
||||
#define SDL_INIT_SENSOR 0x00008000u
|
||||
#define SDL_INIT_NOPARACHUTE 0x00100000u /**< compatibility; this flag is ignored. */
|
||||
#define SDL_INIT_EVERYTHING ( \
|
||||
SDL_INIT_TIMER | SDL_INIT_AUDIO | SDL_INIT_VIDEO | SDL_INIT_EVENTS | \
|
||||
SDL_INIT_JOYSTICK | SDL_INIT_HAPTIC | SDL_INIT_GAMECONTROLLER | SDL_INIT_SENSOR \
|
||||
)
|
||||
/* @} */
|
||||
|
||||
/**
|
||||
* Initialize the SDL library.
|
||||
*
|
||||
* SDL_Init() simply forwards to calling SDL_InitSubSystem(). Therefore, the
|
||||
* two may be used interchangeably. Though for readability of your code
|
||||
* SDL_InitSubSystem() might be preferred.
|
||||
*
|
||||
* The file I/O (for example: SDL_RWFromFile) and threading (SDL_CreateThread)
|
||||
* subsystems are initialized by default. Message boxes
|
||||
* (SDL_ShowSimpleMessageBox) also attempt to work without initializing the
|
||||
* video subsystem, in hopes of being useful in showing an error dialog when
|
||||
* SDL_Init fails. You must specifically initialize other subsystems if you
|
||||
* use them in your application.
|
||||
*
|
||||
* Logging (such as SDL_Log) works without initialization, too.
|
||||
*
|
||||
* `flags` may be any of the following OR'd together:
|
||||
*
|
||||
* - `SDL_INIT_TIMER`: timer subsystem
|
||||
* - `SDL_INIT_AUDIO`: audio subsystem
|
||||
* - `SDL_INIT_VIDEO`: video subsystem; automatically initializes the events
|
||||
* subsystem
|
||||
* - `SDL_INIT_JOYSTICK`: joystick subsystem; automatically initializes the
|
||||
* events subsystem
|
||||
* - `SDL_INIT_HAPTIC`: haptic (force feedback) subsystem
|
||||
* - `SDL_INIT_GAMECONTROLLER`: controller subsystem; automatically
|
||||
* initializes the joystick subsystem
|
||||
* - `SDL_INIT_EVENTS`: events subsystem
|
||||
* - `SDL_INIT_EVERYTHING`: all of the above subsystems
|
||||
* - `SDL_INIT_NOPARACHUTE`: compatibility; this flag is ignored
|
||||
*
|
||||
* Subsystem initialization is ref-counted, you must call SDL_QuitSubSystem()
|
||||
* for each SDL_InitSubSystem() to correctly shutdown a subsystem manually (or
|
||||
* call SDL_Quit() to force shutdown). If a subsystem is already loaded then
|
||||
* this call will increase the ref-count and return.
|
||||
*
|
||||
* \param flags subsystem initialization flags
|
||||
* \returns 0 on success or a negative error code on failure; call
|
||||
* SDL_GetError() for more information.
|
||||
*
|
||||
* \since This function is available since SDL 2.0.0.
|
||||
*
|
||||
* \sa SDL_InitSubSystem
|
||||
* \sa SDL_Quit
|
||||
* \sa SDL_SetMainReady
|
||||
* \sa SDL_WasInit
|
||||
*/
|
||||
extern DECLSPEC int SDLCALL SDL_Init(Uint32 flags);
|
||||
|
||||
/**
|
||||
* Compatibility function to initialize the SDL library.
|
||||
*
|
||||
* In SDL2, this function and SDL_Init() are interchangeable.
|
||||
*
|
||||
* \param flags any of the flags used by SDL_Init(); see SDL_Init for details.
|
||||
* \returns 0 on success or a negative error code on failure; call
|
||||
* SDL_GetError() for more information.
|
||||
*
|
||||
* \since This function is available since SDL 2.0.0.
|
||||
*
|
||||
* \sa SDL_Init
|
||||
* \sa SDL_Quit
|
||||
* \sa SDL_QuitSubSystem
|
||||
*/
|
||||
extern DECLSPEC int SDLCALL SDL_InitSubSystem(Uint32 flags);
|
||||
|
||||
/**
|
||||
* Shut down specific SDL subsystems.
|
||||
*
|
||||
* If you start a subsystem using a call to that subsystem's init function
|
||||
* (for example SDL_VideoInit()) instead of SDL_Init() or SDL_InitSubSystem(),
|
||||
* SDL_QuitSubSystem() and SDL_WasInit() will not work. You will need to use
|
||||
* that subsystem's quit function (SDL_VideoQuit()) directly instead. But
|
||||
* generally, you should not be using those functions directly anyhow; use
|
||||
* SDL_Init() instead.
|
||||
*
|
||||
* You still need to call SDL_Quit() even if you close all open subsystems
|
||||
* with SDL_QuitSubSystem().
|
||||
*
|
||||
* \param flags any of the flags used by SDL_Init(); see SDL_Init for details.
|
||||
*
|
||||
* \since This function is available since SDL 2.0.0.
|
||||
*
|
||||
* \sa SDL_InitSubSystem
|
||||
* \sa SDL_Quit
|
||||
*/
|
||||
extern DECLSPEC void SDLCALL SDL_QuitSubSystem(Uint32 flags);
|
||||
|
||||
/**
|
||||
* Get a mask of the specified subsystems which are currently initialized.
|
||||
*
|
||||
* \param flags any of the flags used by SDL_Init(); see SDL_Init for details.
|
||||
* \returns a mask of all initialized subsystems if `flags` is 0, otherwise it
|
||||
* returns the initialization status of the specified subsystems.
|
||||
*
|
||||
* The return value does not include SDL_INIT_NOPARACHUTE.
|
||||
*
|
||||
* \since This function is available since SDL 2.0.0.
|
||||
*
|
||||
* \sa SDL_Init
|
||||
* \sa SDL_InitSubSystem
|
||||
*/
|
||||
extern DECLSPEC Uint32 SDLCALL SDL_WasInit(Uint32 flags);
|
||||
|
||||
/**
|
||||
* Clean up all initialized subsystems.
|
||||
*
|
||||
* You should call this function even if you have already shutdown each
|
||||
* initialized subsystem with SDL_QuitSubSystem(). It is safe to call this
|
||||
* function even in the case of errors in initialization.
|
||||
*
|
||||
* If you start a subsystem using a call to that subsystem's init function
|
||||
* (for example SDL_VideoInit()) instead of SDL_Init() or SDL_InitSubSystem(),
|
||||
* then you must use that subsystem's quit function (SDL_VideoQuit()) to shut
|
||||
* it down before calling SDL_Quit(). But generally, you should not be using
|
||||
* those functions directly anyhow; use SDL_Init() instead.
|
||||
*
|
||||
* You can use this function with atexit() to ensure that it is run when your
|
||||
* application is shutdown, but it is not wise to do this from a library or
|
||||
* other dynamically loaded code.
|
||||
*
|
||||
* \since This function is available since SDL 2.0.0.
|
||||
*
|
||||
* \sa SDL_Init
|
||||
* \sa SDL_QuitSubSystem
|
||||
*/
|
||||
extern DECLSPEC void SDLCALL SDL_Quit(void);
|
||||
|
||||
/* Ends C function definitions when using C++ */
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#include "close_code.h"
|
||||
|
||||
#endif /* SDL_h_ */
|
||||
|
||||
/* vi: set ts=4 sw=4 expandtab: */
|
||||
98
LibWhisper/WhisperStream.swift
Normal file
98
LibWhisper/WhisperStream.swift
Normal file
@ -0,0 +1,98 @@
|
||||
import AVFoundation
|
||||
|
||||
public struct Segment {
|
||||
let text: String
|
||||
let t0: Int64
|
||||
let t1: Int64
|
||||
}
|
||||
|
||||
public typealias OrderedSegments = [Segment]
|
||||
|
||||
public extension OrderedSegments {
|
||||
var text: any StringProtocol {
|
||||
map { $0.text }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
public class WhisperStream: Thread {
|
||||
let waiter = DispatchGroup()
|
||||
|
||||
@Published public private(set) var segments = OrderedSegments()
|
||||
@Published public private(set) var alive = true
|
||||
|
||||
let model: URL
|
||||
let device: CaptureDevice?
|
||||
let window: TimeInterval
|
||||
|
||||
public init(model: URL, device: CaptureDevice? = nil, window: TimeInterval = 300) {
|
||||
self.model = model
|
||||
self.device = device
|
||||
self.window = window
|
||||
super.init()
|
||||
}
|
||||
|
||||
public override func start() {
|
||||
waiter.enter()
|
||||
super.start()
|
||||
}
|
||||
|
||||
public override func main() {
|
||||
task()
|
||||
waiter.leave()
|
||||
}
|
||||
|
||||
public func join() {
|
||||
waiter.wait()
|
||||
}
|
||||
|
||||
func task() {
|
||||
model.path.withCString { modelCStr in
|
||||
var params = stream_default_params()
|
||||
params.model = modelCStr
|
||||
|
||||
if let device = device {
|
||||
params.capture_id = device.id
|
||||
}
|
||||
|
||||
let ctx = stream_init(params)
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
while !self.isCancelled {
|
||||
let errno = stream_run(ctx, Unmanaged.passUnretained(self).toOpaque()) {
|
||||
return Unmanaged<WhisperStream>.fromOpaque($3!).takeUnretainedValue().callback(
|
||||
text: $0 != nil ? String(cString: $0!) : nil,
|
||||
t0: $1,
|
||||
t1: $2
|
||||
)
|
||||
}
|
||||
if errno != 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
stream_free(ctx)
|
||||
alive = false
|
||||
}
|
||||
}
|
||||
|
||||
func callback(text: String?, t0: Int64, t1: Int64) -> Int32 {
|
||||
if segments.isEmpty || text == nil {
|
||||
segments.append(Segment(text: "", t0: -1, t1: -1))
|
||||
}
|
||||
if let text = text {
|
||||
segments[segments.count - 1] = Segment(text: text, t0: t0, t1: t1)
|
||||
}
|
||||
|
||||
var k = 0
|
||||
for segment in segments {
|
||||
if let last = segments.last, last.t0 - segment.t0 > Int64(window * 1000) {
|
||||
k += 1
|
||||
}
|
||||
}
|
||||
segments.removeFirst(k)
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
||||
240
LibWhisper/stream.cpp
Normal file
240
LibWhisper/stream.cpp
Normal file
@ -0,0 +1,240 @@
|
||||
// This code is based on the streaming example provided with whisper.cpp:
|
||||
// https://github.com/ggerganov/whisper.cpp/blob/ca21f7ab16694384fb74b1ba4f68b39f16540d23/examples/stream/stream.cpp
|
||||
|
||||
#include "common.h"
|
||||
#include "common-sdl.h"
|
||||
#include "whisper.h"
|
||||
#include "stream.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <fstream>
|
||||
|
||||
using unique_whisper = std::unique_ptr<whisper_context, std::integral_constant<decltype(&whisper_free), &whisper_free>>;
|
||||
|
||||
struct stream_context {
|
||||
stream_params params;
|
||||
std::unique_ptr<audio_async> audio;
|
||||
unique_whisper whisper;
|
||||
std::vector<float> pcmf32;
|
||||
std::vector<float> pcmf32_old;
|
||||
std::vector<float> pcmf32_new;
|
||||
std::vector<whisper_token> prompt_tokens;
|
||||
std::chrono::time_point<std::chrono::high_resolution_clock> t_last;
|
||||
std::chrono::time_point<std::chrono::high_resolution_clock> t_start;
|
||||
int n_samples_step;
|
||||
int n_samples_len;
|
||||
int n_samples_keep;
|
||||
bool use_vad;
|
||||
int n_new_line;
|
||||
int n_iter = 0;
|
||||
};
|
||||
|
||||
struct stream_params stream_default_params() {
|
||||
return stream_params {
|
||||
/* .n_threads =*/ std::min(4, (int32_t) std::thread::hardware_concurrency()),
|
||||
/* .step_ms =*/ 3000,
|
||||
/* .length_ms =*/ 10000,
|
||||
/* .keep_ms =*/ 200,
|
||||
/* .capture_id =*/ -1,
|
||||
/* .max_tokens =*/ 32,
|
||||
/* .audio_ctx =*/ 0,
|
||||
|
||||
/* .vad_thold =*/ 0.6f,
|
||||
/* .freq_thold =*/ 100.0f,
|
||||
|
||||
/* .speed_up =*/ false,
|
||||
/* .translate =*/ false,
|
||||
/* .print_special =*/ false,
|
||||
/* .no_context =*/ true,
|
||||
/* .no_timestamps =*/ false,
|
||||
|
||||
/* .language =*/ "en",
|
||||
/* .model =*/ "models/ggml-base.en.bin"
|
||||
};
|
||||
}
|
||||
|
||||
stream_context *stream_init(stream_params params) {
|
||||
auto ctx = std::make_unique<stream_context>();
|
||||
|
||||
params.keep_ms = std::min(params.keep_ms, params.step_ms);
|
||||
params.length_ms = std::max(params.length_ms, params.step_ms);
|
||||
|
||||
ctx->n_samples_step = (1e-3 * params.step_ms) * WHISPER_SAMPLE_RATE;
|
||||
ctx->n_samples_len = (1e-3 * params.length_ms) * WHISPER_SAMPLE_RATE;
|
||||
ctx->n_samples_keep = (1e-3 * params.keep_ms) * WHISPER_SAMPLE_RATE;
|
||||
const int n_samples_30s = (1e-3 * 30000.0) * WHISPER_SAMPLE_RATE;
|
||||
|
||||
ctx->use_vad = ctx->n_samples_step <= 0; // sliding window mode uses VAD
|
||||
|
||||
ctx->n_new_line = !ctx->use_vad ? std::max(1, params.length_ms / params.step_ms - 1) : 1; // number of steps to print new line
|
||||
|
||||
params.no_timestamps = !ctx->use_vad;
|
||||
params.no_context |= ctx->use_vad;
|
||||
params.max_tokens = 0;
|
||||
|
||||
// init audio
|
||||
ctx->audio = std::make_unique<audio_async>(params.length_ms);
|
||||
if (!ctx->audio->init(params.capture_id, WHISPER_SAMPLE_RATE)) {
|
||||
fprintf(stderr, "%s: audio.init() failed!\n", __func__);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
ctx->audio->resume();
|
||||
|
||||
// whisper init
|
||||
if (whisper_lang_id(params.language) == -1) {
|
||||
fprintf(stderr, "%s: unknown language '%s'\n", __func__, params.language);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if ((ctx->whisper = unique_whisper(whisper_init_from_file(params.model))) == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
ctx->pcmf32 = std::vector<float>(n_samples_30s, 0.0f);
|
||||
ctx->pcmf32_new = std::vector<float>(n_samples_30s, 0.0f);
|
||||
|
||||
ctx->t_last = std::chrono::high_resolution_clock::now();
|
||||
ctx->t_start = ctx->t_last;
|
||||
|
||||
ctx->params = params;
|
||||
|
||||
return ctx.release();
|
||||
}
|
||||
|
||||
void stream_free(stream_context *ctx) {
|
||||
ctx->audio = NULL;
|
||||
ctx->whisper = NULL;
|
||||
ctx->pcmf32.clear();
|
||||
ctx->pcmf32_old.clear();
|
||||
ctx->pcmf32_new.clear();
|
||||
ctx->prompt_tokens.clear();
|
||||
}
|
||||
|
||||
int stream_run(stream_context *ctx, void *callback_ctx, stream_callback_t callback) {
|
||||
auto params = ctx->params;
|
||||
auto whisper = ctx->whisper.get();
|
||||
|
||||
auto t_now = std::chrono::high_resolution_clock::now();
|
||||
|
||||
if (!ctx->use_vad) {
|
||||
while (true) {
|
||||
ctx->audio->get(params.step_ms, ctx->pcmf32_new);
|
||||
|
||||
if ((int)ctx->pcmf32_new.size() > 2 * ctx->n_samples_step) {
|
||||
fprintf(stderr, "\n\n%s: WARNING: cannot process audio fast enough, dropping audio ...\n\n", __func__);
|
||||
ctx->audio->clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int)ctx->pcmf32_new.size() >= ctx->n_samples_step) {
|
||||
ctx->audio->clear();
|
||||
break;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
|
||||
const int n_samples_new = ctx->pcmf32_new.size();
|
||||
|
||||
// take up to params.length_ms audio from previous iteration
|
||||
const int n_samples_take = std::min((int)ctx->pcmf32_old.size(), std::max(0, ctx->n_samples_keep + ctx->n_samples_len - n_samples_new));
|
||||
|
||||
ctx->pcmf32.resize(n_samples_new + n_samples_take);
|
||||
|
||||
for (int i = 0; i < n_samples_take; i++) {
|
||||
ctx->pcmf32[i] = ctx->pcmf32_old[ctx->pcmf32_old.size() - n_samples_take + i];
|
||||
}
|
||||
|
||||
memcpy(ctx->pcmf32.data() + n_samples_take, ctx->pcmf32_new.data(), n_samples_new * sizeof(float));
|
||||
|
||||
ctx->pcmf32_old = ctx->pcmf32;
|
||||
} else {
|
||||
auto t_diff = std::chrono::duration_cast<std::chrono::milliseconds>(t_now - ctx->t_last).count();
|
||||
if (t_diff < 2000) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// process new audio
|
||||
ctx->audio->get(2000, ctx->pcmf32_new);
|
||||
|
||||
if (::vad_simple(ctx->pcmf32_new, WHISPER_SAMPLE_RATE, 1000, params.vad_thold, params.freq_thold, false)) {
|
||||
ctx->audio->get(params.length_ms, ctx->pcmf32);
|
||||
} else {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
return 0;
|
||||
}
|
||||
|
||||
ctx->t_last = t_now;
|
||||
}
|
||||
|
||||
// run the inference
|
||||
whisper_full_params wparams = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
|
||||
|
||||
wparams.print_progress = false;
|
||||
wparams.print_special = params.print_special;
|
||||
wparams.print_realtime = false;
|
||||
wparams.print_timestamps = !params.no_timestamps;
|
||||
wparams.translate = params.translate;
|
||||
wparams.no_context = true;
|
||||
wparams.single_segment = !ctx->use_vad;
|
||||
wparams.max_tokens = params.max_tokens;
|
||||
wparams.language = params.language;
|
||||
wparams.n_threads = params.n_threads;
|
||||
|
||||
wparams.audio_ctx = params.audio_ctx;
|
||||
wparams.speed_up = params.speed_up;
|
||||
|
||||
// disable temperature fallback
|
||||
wparams.temperature_inc = -1.0f;
|
||||
|
||||
wparams.prompt_tokens = params.no_context ? nullptr : ctx->prompt_tokens.data();
|
||||
wparams.prompt_n_tokens = params.no_context ? 0 : ctx->prompt_tokens.size();
|
||||
|
||||
const int64_t t1 = (t_now - ctx->t_start).count() / 1000000;
|
||||
const int64_t t0 = std::max(0.0, t1 - ctx->pcmf32.size() * 1000.0 / WHISPER_SAMPLE_RATE);
|
||||
|
||||
if (whisper_full(whisper, wparams, ctx->pcmf32.data(), ctx->pcmf32.size()) != 0) {
|
||||
fprintf(stderr, "%s: failed to process audio\n", __func__);
|
||||
return 6;
|
||||
}
|
||||
|
||||
const int n_segments = whisper_full_n_segments(whisper);
|
||||
for (int i = 0; i < n_segments; ++i) {
|
||||
const char *text = whisper_full_get_segment_text(whisper, i);
|
||||
|
||||
const int64_t segment_t0 = whisper_full_get_segment_t0(whisper, i);
|
||||
const int64_t segment_t1 = whisper_full_get_segment_t1(whisper, i);
|
||||
|
||||
callback(text, ctx->use_vad ? segment_t0 : t0, ctx->use_vad ? segment_t1 : t1, callback_ctx);
|
||||
}
|
||||
|
||||
++ctx->n_iter;
|
||||
|
||||
if (!ctx->use_vad && (ctx->n_iter % ctx->n_new_line) == 0) {
|
||||
callback(NULL, 0, 0, callback_ctx);
|
||||
|
||||
// keep part of the audio for next iteration to try to mitigate word boundary issues
|
||||
ctx->pcmf32_old = std::vector<float>(ctx->pcmf32.end() - ctx->n_samples_keep, ctx->pcmf32.end());
|
||||
|
||||
// Add tokens of the last full length segment as the prompt
|
||||
if (!params.no_context) {
|
||||
ctx->prompt_tokens.clear();
|
||||
|
||||
const int n_segments = whisper_full_n_segments(whisper);
|
||||
for (int i = 0; i < n_segments; ++i) {
|
||||
const int token_count = whisper_full_n_tokens(whisper, i);
|
||||
for (int j = 0; j < token_count; ++j) {
|
||||
ctx->prompt_tokens.push_back(whisper_full_get_token_id(whisper, i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
42
LibWhisper/stream.h
Normal file
42
LibWhisper/stream.h
Normal file
@ -0,0 +1,42 @@
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct stream_params {
|
||||
int32_t n_threads;
|
||||
int32_t step_ms;
|
||||
int32_t length_ms;
|
||||
int32_t keep_ms;
|
||||
int32_t capture_id;
|
||||
int32_t max_tokens;
|
||||
int32_t audio_ctx;
|
||||
|
||||
float vad_thold;
|
||||
float freq_thold;
|
||||
|
||||
bool speed_up;
|
||||
bool translate;
|
||||
bool print_special;
|
||||
bool no_context;
|
||||
bool no_timestamps;
|
||||
|
||||
const char *language;
|
||||
const char *model;
|
||||
} stream_params_t;
|
||||
|
||||
stream_params_t stream_default_params();
|
||||
|
||||
typedef struct stream_context *stream_context_t;
|
||||
|
||||
stream_context_t stream_init(stream_params_t params);
|
||||
void stream_free(stream_context_t ctx);
|
||||
|
||||
typedef int (*stream_callback_t) (const char *text, int64_t t0, int64_t t1, void *ctx);
|
||||
int stream_run(stream_context_t ctx, void *callback_ctx, stream_callback_t callback);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
21
OpenAISwift/LICENSE
Normal file
21
OpenAISwift/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Adam Rush
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
85
OpenAISwift/Models/ChatMessage.swift
Normal file
85
OpenAISwift/Models/ChatMessage.swift
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Bogdan Farca on 02.03.2023.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// An enumeration of possible roles in a chat conversation.
|
||||
public enum ChatRole: String, Codable {
|
||||
/// The role for the system that manages the chat interface.
|
||||
case system
|
||||
/// The role for the human user who initiates the chat.
|
||||
case user
|
||||
/// The role for the artificial assistant who responds to the user.
|
||||
case assistant
|
||||
}
|
||||
|
||||
/// A structure that represents a single message in a chat conversation.
|
||||
public struct ChatMessage: Codable {
|
||||
/// The role of the sender of the message.
|
||||
public let role: ChatRole
|
||||
/// The content of the message.
|
||||
public let content: String
|
||||
|
||||
/// Creates a new chat message with a given role and content.
|
||||
/// - Parameters:
|
||||
/// - role: The role of the sender of the message.
|
||||
/// - content: The content of the message.
|
||||
public init(role: ChatRole, content: String) {
|
||||
self.role = role
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
/// A structure that represents a chat conversation.
|
||||
public struct ChatConversation: Encodable {
|
||||
/// The name or identifier of the user who initiates the chat. Optional if not provided by the user interface.
|
||||
let user: String?
|
||||
|
||||
/// The messages to generate chat completions for. Ordered chronologically from oldest to newest.
|
||||
let messages: [ChatMessage]
|
||||
|
||||
/// The ID of the model used by the assistant to generate responses. See OpenAI documentation for details on which models work with the Chat API.
|
||||
let model: String
|
||||
|
||||
/// A parameter that controls how random or deterministic the responses are, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. Optional, defaults to 1.
|
||||
let temperature: Double?
|
||||
|
||||
/// A parameter that controls how diverse or narrow-minded the responses are, between 0 and 1. Higher values like 0.9 mean only the tokens comprising the top 90% probability mass are considered, while lower values like 0.1 mean only the top 10%. Optional, defaults to 1.
|
||||
let topProbabilityMass: Double?
|
||||
|
||||
/// How many chat completion choices to generate for each input message. Optional, defaults to 1.
|
||||
let choices: Int?
|
||||
|
||||
/// An array of up to 4 sequences where the API will stop generating further tokens. Optional.
|
||||
let stop: [String]?
|
||||
|
||||
/// The maximum number of tokens to generate in the chat completion. The total length of input tokens and generated tokens is limited by the model's context length. Optional.
|
||||
let maxTokens: Int?
|
||||
|
||||
/// A parameter that penalizes new tokens based on whether they appear in the text so far, between -2 and 2. Positive values increase the model's likelihood to talk about new topics. Optional if not specified by default or by user input. Optional, defaults to 0.
|
||||
let presencePenalty: Double?
|
||||
|
||||
/// A parameter that penalizes new tokens based on their existing frequency in the text so far, between -2 and 2. Positive values decrease the model's likelihood to repeat the same line verbatim. Optional if not specified by default or by user input. Optional, defaults to 0.
|
||||
let frequencyPenalty: Double?
|
||||
|
||||
/// Modify the likelihood of specified tokens appearing in the completion. Maps tokens (specified by their token ID in the OpenAI Tokenizer—not English words) to an associated bias value from -100 to 100. Values between -1 and 1 should decrease or increase likelihood of selection; values like -100 or 100 should result in a ban or exclusive selection of the relevant token.
|
||||
let logitBias: [Int: Double]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case user
|
||||
case messages
|
||||
case model
|
||||
case temperature
|
||||
case topProbabilityMass = "top_p"
|
||||
case choices = "n"
|
||||
case stop
|
||||
case maxTokens = "max_tokens"
|
||||
case presencePenalty = "presence_penalty"
|
||||
case frequencyPenalty = "frequency_penalty"
|
||||
case logitBias = "logit_bias"
|
||||
}
|
||||
}
|
||||
19
OpenAISwift/Models/Command.swift
Normal file
19
OpenAISwift/Models/Command.swift
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// Created by Adam Rush - OpenAISwift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Command: Encodable {
|
||||
let prompt: String
|
||||
let model: String
|
||||
let maxTokens: Int
|
||||
let temperature: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case prompt
|
||||
case model
|
||||
case maxTokens = "max_tokens"
|
||||
case temperature
|
||||
}
|
||||
}
|
||||
21
OpenAISwift/Models/ImageGeneration.swift
Normal file
21
OpenAISwift/Models/ImageGeneration.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// ImageGeneration.swift
|
||||
//
|
||||
//
|
||||
// Created by Arjun Dureja on 2023-03-11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ImageGeneration: Encodable {
|
||||
let prompt: String
|
||||
let n: Int
|
||||
let size: ImageSize
|
||||
let user: String?
|
||||
}
|
||||
|
||||
public enum ImageSize: String, Codable {
|
||||
case size1024 = "1024x1024"
|
||||
case size512 = "512x512"
|
||||
case size256 = "256x256"
|
||||
}
|
||||
11
OpenAISwift/Models/Instruction.swift
Normal file
11
OpenAISwift/Models/Instruction.swift
Normal file
@ -0,0 +1,11 @@
|
||||
//
|
||||
// Created by Adam Rush - OpenAISwift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Instruction: Encodable {
|
||||
let instruction: String
|
||||
let model: String
|
||||
let input: String
|
||||
}
|
||||
39
OpenAISwift/Models/OpenAI.swift
Normal file
39
OpenAISwift/Models/OpenAI.swift
Normal file
@ -0,0 +1,39 @@
|
||||
//
|
||||
// Created by Adam Rush - OpenAISwift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol Payload: Codable { }
|
||||
|
||||
public struct OpenAI<T: Payload>: Codable {
|
||||
public let object: String?
|
||||
public let model: String?
|
||||
public let choices: [T]?
|
||||
public let usage: UsageResult?
|
||||
public let data: [T]?
|
||||
}
|
||||
|
||||
public struct TextResult: Payload {
|
||||
public let text: String
|
||||
}
|
||||
|
||||
public struct MessageResult: Payload {
|
||||
public let message: ChatMessage
|
||||
}
|
||||
|
||||
public struct UsageResult: Codable {
|
||||
public let promptTokens: Int
|
||||
public let completionTokens: Int
|
||||
public let totalTokens: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case promptTokens = "prompt_tokens"
|
||||
case completionTokens = "completion_tokens"
|
||||
case totalTokens = "total_tokens"
|
||||
}
|
||||
}
|
||||
|
||||
public struct UrlResult: Payload {
|
||||
public let url: String
|
||||
}
|
||||
120
OpenAISwift/Models/OpenAIModelType.swift
Normal file
120
OpenAISwift/Models/OpenAIModelType.swift
Normal file
@ -0,0 +1,120 @@
|
||||
//
|
||||
// OpenAIModelType.swift
|
||||
//
|
||||
//
|
||||
// Created by Yash Shah on 06/12/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The type of model used to generate the output
|
||||
public enum OpenAIModelType {
|
||||
/// ``GPT3`` Family of Models
|
||||
case gpt3(GPT3)
|
||||
|
||||
/// ``Codex`` Family of Models
|
||||
case codex(Codex)
|
||||
|
||||
/// ``Feature`` Family of Models
|
||||
case feature(Feature)
|
||||
|
||||
/// ``Chat`` Family of Models
|
||||
case chat(Chat)
|
||||
|
||||
/// Other Custom Models
|
||||
case other(String)
|
||||
|
||||
public var modelName: String {
|
||||
switch self {
|
||||
case .gpt3(let model): return model.rawValue
|
||||
case .codex(let model): return model.rawValue
|
||||
case .feature(let model): return model.rawValue
|
||||
case .chat(let model): return model.rawValue
|
||||
case .other(let modelName): return modelName
|
||||
}
|
||||
}
|
||||
|
||||
/// A set of models that can understand and generate natural language
|
||||
///
|
||||
/// [GPT-3 Models OpenAI API Docs](https://beta.openai.com/docs/models/gpt-3)
|
||||
public enum GPT3: String {
|
||||
|
||||
/// Most capable GPT-3 model. Can do any task the other models can do, often with higher quality, longer output and better instruction-following. Also supports inserting completions within text.
|
||||
///
|
||||
/// > Model Name: text-davinci-003
|
||||
case davinci = "text-davinci-003"
|
||||
|
||||
/// Very capable, but faster and lower cost than GPT3 ``davinci``.
|
||||
///
|
||||
/// > Model Name: text-curie-001
|
||||
case curie = "text-curie-001"
|
||||
|
||||
/// Capable of straightforward tasks, very fast, and lower cost.
|
||||
///
|
||||
/// > Model Name: text-babbage-001
|
||||
case babbage = "text-babbage-001"
|
||||
|
||||
/// Capable of very simple tasks, usually the fastest model in the GPT-3 series, and lowest cost.
|
||||
///
|
||||
/// > Model Name: text-ada-001
|
||||
case ada = "text-ada-001"
|
||||
}
|
||||
|
||||
/// A set of models that can understand and generate code, including translating natural language to code
|
||||
///
|
||||
/// [Codex Models OpenAI API Docs](https://beta.openai.com/docs/models/codex)
|
||||
///
|
||||
/// > Limited Beta
|
||||
public enum Codex: String {
|
||||
/// Most capable Codex model. Particularly good at translating natural language to code. In addition to completing code, also supports inserting completions within code.
|
||||
///
|
||||
/// > Model Name: code-davinci-002
|
||||
case davinci = "code-davinci-002"
|
||||
|
||||
/// Almost as capable as ``davinci`` Codex, but slightly faster. This speed advantage may make it preferable for real-time applications.
|
||||
///
|
||||
/// > Model Name: code-cushman-001
|
||||
case cushman = "code-cushman-001"
|
||||
}
|
||||
|
||||
|
||||
/// A set of models that are feature specific.
|
||||
///
|
||||
/// For example using the Edits endpoint requires a specific data model
|
||||
///
|
||||
/// You can read the [API Docs](https://beta.openai.com/docs/guides/completion/editing-text)
|
||||
public enum Feature: String {
|
||||
|
||||
/// > Model Name: text-davinci-edit-001
|
||||
case davinci = "text-davinci-edit-001"
|
||||
}
|
||||
|
||||
/// A set of models for the new chat completions
|
||||
/// You can read the [API Docs](https://platform.openai.com/docs/api-reference/chat/create)
|
||||
public enum Chat: String {
|
||||
|
||||
/// Most capable GPT-3.5 model and optimized for chat at 1/10th the cost of text-davinci-003. Will be updated with our latest model iteration.
|
||||
/// > Model Name: gpt-3.5-turbo
|
||||
case chatgpt = "gpt-3.5-turbo"
|
||||
|
||||
/// Snapshot of gpt-3.5-turbo from March 1st 2023. Unlike gpt-3.5-turbo, this model will not receive updates, and will only be supported for a three month period ending on June 1st 2023.
|
||||
/// > Model Name: gpt-3.5-turbo-0301
|
||||
case chatgpt0301 = "gpt-3.5-turbo-0301"
|
||||
|
||||
/// More capable than any GPT-3.5 model, able to do more complex tasks, and optimized for chat. Will be updated with our latest model iteration.
|
||||
/// > Model Name: gpt-4
|
||||
case gpt4 = "gpt-4"
|
||||
|
||||
/// Snapshot of gpt-4 from March 14th 2023. Unlike gpt-4, this model will not receive updates, and will only be supported for a three month period ending on June 14th 2023.
|
||||
/// > Model Name: gpt-4-0314
|
||||
case gpt4_0314 = "gpt-4-0314"
|
||||
|
||||
/// Same capabilities as the base gpt-4 mode but with 4x the context length. Will be updated with our latest model iteration.
|
||||
/// > Model Name: gpt-4-32k
|
||||
case gpt4_32k = "gpt-4-32k"
|
||||
|
||||
/// Snapshot of gpt-4-32 from March 14th 2023. Unlike gpt-4-32k, this model will not receive updates, and will only be supported for a three month period ending on June 14th 2023.
|
||||
/// > Model Name: gpt-4-32k-0314
|
||||
case gpt4_32k_0314 = "gpt-4-32k-0314"
|
||||
}
|
||||
}
|
||||
41
OpenAISwift/OpenAIEndpoint.swift
Normal file
41
OpenAISwift/OpenAIEndpoint.swift
Normal file
@ -0,0 +1,41 @@
|
||||
//
|
||||
// Created by Adam Rush - OpenAISwift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Endpoint {
|
||||
case completions
|
||||
case edits
|
||||
case chat
|
||||
case images
|
||||
}
|
||||
|
||||
extension Endpoint {
|
||||
var path: String {
|
||||
switch self {
|
||||
case .completions:
|
||||
return "/v1/completions"
|
||||
case .edits:
|
||||
return "/v1/edits"
|
||||
case .chat:
|
||||
return "/v1/chat/completions"
|
||||
case .images:
|
||||
return "/v1/images/generations"
|
||||
}
|
||||
}
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .completions, .edits, .chat, .images:
|
||||
return "POST"
|
||||
}
|
||||
}
|
||||
|
||||
func baseURL() -> String {
|
||||
switch self {
|
||||
case .completions, .edits, .chat, .images:
|
||||
return "https://api.openai.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
298
OpenAISwift/OpenAISwift.swift
Normal file
298
OpenAISwift/OpenAISwift.swift
Normal file
@ -0,0 +1,298 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking) && canImport(FoundationXML)
|
||||
import FoundationNetworking
|
||||
import FoundationXML
|
||||
#endif
|
||||
|
||||
public enum OpenAIError: Error {
|
||||
case genericError(error: Error)
|
||||
case decodingError(error: Error)
|
||||
}
|
||||
|
||||
public class OpenAISwift {
|
||||
fileprivate(set) var token: String?
|
||||
fileprivate let config: Config
|
||||
|
||||
/// Configuration object for the client
|
||||
public struct Config {
|
||||
|
||||
/// Initialiser
|
||||
/// - Parameter session: the session to use for network requests.
|
||||
public init(session: URLSession = URLSession.shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
let session:URLSession
|
||||
}
|
||||
|
||||
public init(authToken: String, config: Config = Config()) {
|
||||
self.token = authToken
|
||||
self.config = Config()
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenAISwift {
|
||||
/// Send a Completion to the OpenAI API
|
||||
/// - Parameters:
|
||||
/// - prompt: The Text Prompt
|
||||
/// - model: The AI Model to Use. Set to `OpenAIModelType.gpt3(.davinci)` by default which is the most capable model
|
||||
/// - maxTokens: The limit character for the returned response, defaults to 16 as per the API
|
||||
/// - completionHandler: Returns an OpenAI Data Model
|
||||
public func sendCompletion(with prompt: String, model: OpenAIModelType = .gpt3(.davinci), maxTokens: Int = 16, temperature: Double = 1, completionHandler: @escaping (Result<OpenAI<TextResult>, OpenAIError>) -> Void) {
|
||||
let endpoint = Endpoint.completions
|
||||
let body = Command(prompt: prompt, model: model.modelName, maxTokens: maxTokens, temperature: temperature)
|
||||
let request = prepareRequest(endpoint, body: body)
|
||||
|
||||
makeRequest(request: request) { result in
|
||||
switch result {
|
||||
case .success(let success):
|
||||
do {
|
||||
let res = try JSONDecoder().decode(OpenAI<TextResult>.self, from: success)
|
||||
completionHandler(.success(res))
|
||||
} catch {
|
||||
completionHandler(.failure(.decodingError(error: error)))
|
||||
}
|
||||
case .failure(let failure):
|
||||
completionHandler(.failure(.genericError(error: failure)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a Edit request to the OpenAI API
|
||||
/// - Parameters:
|
||||
/// - instruction: The Instruction For Example: "Fix the spelling mistake"
|
||||
/// - model: The Model to use, the only support model is `text-davinci-edit-001`
|
||||
/// - input: The Input For Example "My nam is Adam"
|
||||
/// - completionHandler: Returns an OpenAI Data Model
|
||||
public func sendEdits(with instruction: String, model: OpenAIModelType = .feature(.davinci), input: String = "", completionHandler: @escaping (Result<OpenAI<TextResult>, OpenAIError>) -> Void) {
|
||||
let endpoint = Endpoint.edits
|
||||
let body = Instruction(instruction: instruction, model: model.modelName, input: input)
|
||||
let request = prepareRequest(endpoint, body: body)
|
||||
|
||||
makeRequest(request: request) { result in
|
||||
switch result {
|
||||
case .success(let success):
|
||||
do {
|
||||
let res = try JSONDecoder().decode(OpenAI<TextResult>.self, from: success)
|
||||
completionHandler(.success(res))
|
||||
} catch {
|
||||
completionHandler(.failure(.decodingError(error: error)))
|
||||
}
|
||||
case .failure(let failure):
|
||||
completionHandler(.failure(.genericError(error: failure)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a Chat request to the OpenAI API
|
||||
/// - Parameters:
|
||||
/// - messages: Array of `ChatMessages`
|
||||
/// - model: The Model to use, the only support model is `gpt-3.5-turbo`
|
||||
/// - user: A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse.
|
||||
/// - temperature: What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend altering this or topProbabilityMass but not both.
|
||||
/// - topProbabilityMass: The OpenAI api equivalent of the "top_p" parameter. An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both.
|
||||
/// - choices: How many chat completion choices to generate for each input message.
|
||||
/// - stop: Up to 4 sequences where the API will stop generating further tokens.
|
||||
/// - maxTokens: The maximum number of tokens allowed for the generated answer. By default, the number of tokens the model can return will be (4096 - prompt tokens).
|
||||
/// - presencePenalty: Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.
|
||||
/// - frequencyPenalty: Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.
|
||||
/// - logitBias: Modify the likelihood of specified tokens appearing in the completion. Maps tokens (specified by their token ID in the OpenAI Tokenizer—not English words) to an associated bias value from -100 to 100. Values between -1 and 1 should decrease or increase likelihood of selection; values like -100 or 100 should result in a ban or exclusive selection of the relevant token.
|
||||
/// - completionHandler: Returns an OpenAI Data Model
|
||||
public func sendChat(with messages: [ChatMessage],
|
||||
model: OpenAIModelType = .chat(.chatgpt),
|
||||
user: String? = nil,
|
||||
temperature: Double? = 1,
|
||||
topProbabilityMass: Double? = 0,
|
||||
choices: Int? = 1,
|
||||
stop: [String]? = nil,
|
||||
maxTokens: Int? = nil,
|
||||
presencePenalty: Double? = 0,
|
||||
frequencyPenalty: Double? = 0,
|
||||
logitBias: [Int: Double]? = nil,
|
||||
completionHandler: @escaping (Result<OpenAI<MessageResult>, OpenAIError>) -> Void) {
|
||||
let endpoint = Endpoint.chat
|
||||
let body = ChatConversation(user: user,
|
||||
messages: messages,
|
||||
model: model.modelName,
|
||||
temperature: temperature,
|
||||
topProbabilityMass: topProbabilityMass,
|
||||
choices: choices,
|
||||
stop: stop,
|
||||
maxTokens: maxTokens,
|
||||
presencePenalty: presencePenalty,
|
||||
frequencyPenalty: frequencyPenalty,
|
||||
logitBias: logitBias)
|
||||
|
||||
let request = prepareRequest(endpoint, body: body)
|
||||
|
||||
makeRequest(request: request) { result in
|
||||
switch result {
|
||||
case .success(let success):
|
||||
do {
|
||||
let res = try JSONDecoder().decode(OpenAI<MessageResult>.self, from: success)
|
||||
completionHandler(.success(res))
|
||||
} catch {
|
||||
completionHandler(.failure(.decodingError(error: error)))
|
||||
}
|
||||
case .failure(let failure):
|
||||
completionHandler(.failure(.genericError(error: failure)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a Image generation request to the OpenAI API
|
||||
/// - Parameters:
|
||||
/// - prompt: The Text Prompt
|
||||
/// - numImages: The number of images to generate, defaults to 1
|
||||
/// - size: The size of the image, defaults to 1024x1024. There are two other options: 512x512 and 256x256
|
||||
/// - user: An optional unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse.
|
||||
/// - completionHandler: Returns an OpenAI Data Model
|
||||
public func sendImages(with prompt: String, numImages: Int = 1, size: ImageSize = .size1024, user: String? = nil, completionHandler: @escaping (Result<OpenAI<UrlResult>, OpenAIError>) -> Void) {
|
||||
let endpoint = Endpoint.images
|
||||
let body = ImageGeneration(prompt: prompt, n: numImages, size: size, user: user)
|
||||
let request = prepareRequest(endpoint, body: body)
|
||||
|
||||
makeRequest(request: request) { result in
|
||||
switch result {
|
||||
case .success(let success):
|
||||
do {
|
||||
let res = try JSONDecoder().decode(OpenAI<UrlResult>.self, from: success)
|
||||
completionHandler(.success(res))
|
||||
} catch {
|
||||
completionHandler(.failure(.decodingError(error: error)))
|
||||
}
|
||||
case .failure(let failure):
|
||||
completionHandler(.failure(.genericError(error: failure)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRequest(request: URLRequest, completionHandler: @escaping (Result<Data, Error>) -> Void) {
|
||||
let session = config.session
|
||||
let task = session.dataTask(with: request) { (data, response, error) in
|
||||
if let error = error {
|
||||
completionHandler(.failure(error))
|
||||
} else if let data = data {
|
||||
completionHandler(.success(data))
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
private func prepareRequest<BodyType: Encodable>(_ endpoint: Endpoint, body: BodyType) -> URLRequest {
|
||||
var urlComponents = URLComponents(url: URL(string: endpoint.baseURL())!, resolvingAgainstBaseURL: true)
|
||||
urlComponents?.path = endpoint.path
|
||||
var request = URLRequest(url: urlComponents!.url!)
|
||||
request.httpMethod = endpoint.method
|
||||
|
||||
if let token = self.token {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
if let encoded = try? encoder.encode(body) {
|
||||
request.httpBody = encoded
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenAISwift {
|
||||
/// Send a Completion to the OpenAI API
|
||||
/// - Parameters:
|
||||
/// - prompt: The Text Prompt
|
||||
/// - model: The AI Model to Use. Set to `OpenAIModelType.gpt3(.davinci)` by default which is the most capable model
|
||||
/// - maxTokens: The limit character for the returned response, defaults to 16 as per the API
|
||||
/// - temperature: Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. Defaults to 1
|
||||
/// - Returns: Returns an OpenAI Data Model
|
||||
@available(swift 5.5)
|
||||
@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
|
||||
public func sendCompletion(with prompt: String, model: OpenAIModelType = .gpt3(.davinci), maxTokens: Int = 16, temperature: Double = 1) async throws -> OpenAI<TextResult> {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
sendCompletion(with: prompt, model: model, maxTokens: maxTokens, temperature: temperature) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a Edit request to the OpenAI API
|
||||
/// - Parameters:
|
||||
/// - instruction: The Instruction For Example: "Fix the spelling mistake"
|
||||
/// - model: The Model to use, the only support model is `text-davinci-edit-001`
|
||||
/// - input: The Input For Example "My nam is Adam"
|
||||
/// - Returns: Returns an OpenAI Data Model
|
||||
@available(swift 5.5)
|
||||
@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
|
||||
public func sendEdits(with instruction: String, model: OpenAIModelType = .feature(.davinci), input: String = "") async throws -> OpenAI<TextResult> {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
sendEdits(with: instruction, model: model, input: input) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a Chat request to the OpenAI API
|
||||
/// - Parameters:
|
||||
/// - messages: Array of `ChatMessages`
|
||||
/// - model: The Model to use, the only support model is `gpt-3.5-turbo`
|
||||
/// - user: A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse.
|
||||
/// - temperature: What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend altering this or topProbabilityMass but not both.
|
||||
/// - topProbabilityMass: The OpenAI api equivalent of the "top_p" parameter. An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both.
|
||||
/// - choices: How many chat completion choices to generate for each input message.
|
||||
/// - stop: Up to 4 sequences where the API will stop generating further tokens.
|
||||
/// - maxTokens: The maximum number of tokens allowed for the generated answer. By default, the number of tokens the model can return will be (4096 - prompt tokens).
|
||||
/// - presencePenalty: Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.
|
||||
/// - frequencyPenalty: Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.
|
||||
/// - logitBias: Modify the likelihood of specified tokens appearing in the completion. Maps tokens (specified by their token ID in the OpenAI Tokenizer—not English words) to an associated bias value from -100 to 100. Values between -1 and 1 should decrease or increase likelihood of selection; values like -100 or 100 should result in a ban or exclusive selection of the relevant token.
|
||||
/// - completionHandler: Returns an OpenAI Data Model
|
||||
@available(swift 5.5)
|
||||
@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
|
||||
public func sendChat(with messages: [ChatMessage],
|
||||
model: OpenAIModelType = .chat(.chatgpt),
|
||||
user: String? = nil,
|
||||
temperature: Double? = 1,
|
||||
topProbabilityMass: Double? = 0,
|
||||
choices: Int? = 1,
|
||||
stop: [String]? = nil,
|
||||
maxTokens: Int? = nil,
|
||||
presencePenalty: Double? = 0,
|
||||
frequencyPenalty: Double? = 0,
|
||||
logitBias: [Int: Double]? = nil) async throws -> OpenAI<MessageResult> {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
sendChat(with: messages,
|
||||
model: model,
|
||||
user: user,
|
||||
temperature: temperature,
|
||||
topProbabilityMass: topProbabilityMass,
|
||||
choices: choices,
|
||||
stop: stop,
|
||||
maxTokens: maxTokens,
|
||||
presencePenalty: presencePenalty,
|
||||
frequencyPenalty: frequencyPenalty,
|
||||
logitBias: logitBias) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a Image generation request to the OpenAI API
|
||||
/// - Parameters:
|
||||
/// - prompt: The Text Prompt
|
||||
/// - numImages: The number of images to generate, defaults to 1
|
||||
/// - size: The size of the image, defaults to 1024x1024. There are two other options: 512x512 and 256x256
|
||||
/// - user: An optional unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse.
|
||||
/// - Returns: Returns an OpenAI Data Model
|
||||
@available(swift 5.5)
|
||||
@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
|
||||
public func sendImages(with prompt: String, numImages: Int = 1, size: ImageSize = .size1024, user: String? = nil) async throws -> OpenAI<UrlResult> {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
sendImages(with: prompt, numImages: numImages, size: size, user: user) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
README.md
Normal file
53
README.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Cheetah
|
||||
|
||||
Cheetah is an AI-powered macOS app designed to assist users during remote software engineering interviews by providing real-time, discreet coaching and live coding platform integration.
|
||||
|
||||

|
||||
|
||||
With Cheetah, you can improve your interview performance and increase your chances of landing that $300k SWE job, without spending your weekends cramming leetcode challenges and memorizing algorithms you'll never use.
|
||||
|
||||
|
||||
## How it works
|
||||
|
||||
Cheetah leverages Whisper for real-time audio transcription and GPT-4 for generating hints and solutions. You need to have your own OpenAI API key to use the app. If you don't have access to GPT-4, gpt-3.5-turbo can be used as an alternative.
|
||||
|
||||
Whisper runs locally on your system, utilizing Georgi Gerganov's [whisper.cpp](https://github.com/ggerganov/whisper.cpp). A recent M1 or M2 Mac is required for optimal performance.
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
### Audio driver setup
|
||||
|
||||
For the best results, ensure the audio input captures both sides of the conversation.
|
||||
|
||||
When using a video chat app like Zoom or Google Meet, you can achieve this with [BlackHole](https://existential.audio/blackhole/), a free audio loopback driver. Follow the instructions for setting up a [Multi-Output Device](https://github.com/ExistentialAudio/BlackHole/wiki/Multi-Output-Device), and remember not to use the loopback device as input for the video chat app.
|
||||
|
||||
### App overview
|
||||
|
||||
Open the app and select an audio input to start live transcription. A snippet of the transcription will be displayed under the audio input selector.
|
||||
|
||||
*Note:* running the app in debug mode will result in very slow audio transcription performance.
|
||||
|
||||
The UI features three buttons:
|
||||
|
||||
**Answer:** Generates an answer for the interviewer's question.
|
||||
|
||||
**Refine:** Updates the existing answer, useful for when the interviewer provides additional constraints or clarification.
|
||||
|
||||
**Analyze:** Analyzes code and logs from the live coding environment in your web browser. Requires the browser extension.
|
||||
|
||||
You can also select (highlight) a portion of a generated answer and click Refine to get more detail.
|
||||
|
||||
### Installing the browser extension
|
||||
|
||||
Currently, only Firefox is supported. Follow these steps to install the extension:
|
||||
|
||||
1. Go to [about:debugging](https://firefox-source-docs.mozilla.org/devtools-user/about_colon_debugging/index.html)
|
||||
2. Click "This Firefox"
|
||||
3. Click "Load Temporary Add-on"
|
||||
4. Select `./extension/manifest.json`
|
||||
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Cheetah is a satirical art project and is not intended for use in real-world settings. It may generate incorrect or inappropriate solutions. Users should exercise caution and take responsibility for the information provided by the app.
|
||||
BIN
cheetah.jpg
Normal file
BIN
cheetah.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
12
extension/background.js
Normal file
12
extension/background.js
Normal file
@ -0,0 +1,12 @@
|
||||
let port = null;
|
||||
|
||||
browser.runtime.onMessage.addListener(message => {
|
||||
if (port == null || port.error) {
|
||||
port = browser.runtime.connectNative('cheetah');
|
||||
}
|
||||
try {
|
||||
port.postMessage(message);
|
||||
} catch (error) {
|
||||
port = null;
|
||||
}
|
||||
});
|
||||
23
extension/cheetah.js
Normal file
23
extension/cheetah.js
Normal file
@ -0,0 +1,23 @@
|
||||
let updateFrequency = 1000;
|
||||
|
||||
function update() {
|
||||
let editor = window.wrappedJSObject.editor;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
let modeId = document.querySelector('.react-monaco-editor-react')?.dataset.modeId;
|
||||
let fileUri = document.querySelector('.monaco-editor[role="code"]')?.dataset.uri;
|
||||
let terminal = document.querySelector('.terminal .xterm-accessibility')?.innerText.trim();
|
||||
|
||||
let message = {
|
||||
mode: modeId,
|
||||
files: { [fileUri]: editor.getValue() },
|
||||
logs: { terminal }
|
||||
};
|
||||
|
||||
message.navigationStart = performance.timing.navigationStart;
|
||||
browser.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
setInterval(update, updateFrequency);
|
||||
BIN
extension/icon.png
Normal file
BIN
extension/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 B |
35
extension/manifest.json
Normal file
35
extension/manifest.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"description": "Cheetah browser extension",
|
||||
"manifest_version": 2,
|
||||
"name": "Cheetah",
|
||||
"version": "1.0",
|
||||
"homepage_url": "https://phrack.org",
|
||||
"icons": {
|
||||
"48": "icon.png"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"*://localhost/*",
|
||||
"https://app.coderpad.io/*"
|
||||
],
|
||||
"js": [
|
||||
"cheetah.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "cheetah@phrack.org",
|
||||
"strict_min_version": "50.0"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"scripts": [
|
||||
"background.js"
|
||||
]
|
||||
},
|
||||
"permissions": [
|
||||
"nativeMessaging"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user