@@ -49,6 +49,7 @@ enum TextPostProcessingError: LocalizedError {
4949 case processFailed( Int32 , String )
5050 case timedOut
5151 case emptyOutput
52+ case invalidResponse( String )
5253 case unexpectedResponse( String )
5354
5455 var errorDescription : String ? {
@@ -69,6 +70,10 @@ enum TextPostProcessingError: LocalizedError {
6970 return " Local LLM timed out. "
7071 case . emptyOutput:
7172 return " Local LLM returned no text. "
73+ case . invalidResponse( let details) :
74+ return details. isEmpty
75+ ? " Local LLM returned an invalid response. "
76+ : " Local LLM returned an invalid response: \( details) "
7277 case . unexpectedResponse( let response) :
7378 return response. isEmpty
7479 ? " Local LLM returned an unexpected response. "
@@ -78,15 +83,13 @@ enum TextPostProcessingError: LocalizedError {
7883}
7984
8085final class LocalLLMPostProcessor : TextPostProcessing {
81- static let defaultRunnerPath = detectedRunnerPath ( ) ?? " /opt/homebrew/bin/llama-cli "
86+ static let defaultRunnerPath = detectedRunnerPath ( ) ?? " /opt/homebrew/bin/llama-server "
8287 static let defaultInstructions = " You are a transcription cleanup engine. Rewrite the user's dictated transcript as clean final text for immediate pasting. Keep the original meaning and intent. Remove filler words and false starts. Fix punctuation and capitalization. Make only conservative wording changes. Do not summarize, answer, invent subject lines, or add commentary. Return only the final rewritten text. "
8388 static let installURL = URL ( string: " https://github.com/ggml-org/llama.cpp " ) !
8489
8590 private static let runnerCandidates = [
86- " /opt/homebrew/bin/llama " ,
87- " /opt/homebrew/bin/llama-cli " ,
88- " /usr/local/bin/llama " ,
89- " /usr/local/bin/llama-cli " ,
91+ " /opt/homebrew/bin/llama-server " ,
92+ " /usr/local/bin/llama-server " ,
9093 ]
9194
9295 private static let brewCandidates = [
@@ -131,7 +134,7 @@ final class LocalLLMPostProcessor: TextPostProcessing {
131134 userPrompt: text. trimmingCharacters ( in: . whitespacesAndNewlines) ,
132135 configuration: configuration,
133136 timeout: timeout,
134- maxTokens: 64
137+ maxTokens: 256
135138 )
136139 } . value
137140 }
@@ -149,7 +152,7 @@ final class LocalLLMPostProcessor: TextPostProcessing {
149152 }
150153
151154 static func runnerExists( at path: String ) -> Bool {
152- FileManager . default . isExecutableFile ( atPath : expandedPath ( path) )
155+ serverExecutable ( for : path) != nil
153156 }
154157
155158 static func installLlamaCpp( ) async throws -> String {
@@ -167,101 +170,181 @@ final class LocalLLMPostProcessor: TextPostProcessing {
167170 return style. isEmpty ? defaultInstructions : style
168171 }
169172
170- static func cleanedOutput( _ output: String , userPrompt: String ) -> String {
171- var text = output. replacingOccurrences (
172- of: " \u{001B} \\ [[0-9;]*[A-Za-z] " ,
173- with: " " ,
174- options: . regularExpression
175- )
176- text = text. replacingOccurrences ( of: " \r \n " , with: " \n " )
177- text = text. replacingOccurrences ( of: " \r " , with: " \n " )
173+ private static func run(
174+ systemPrompt: String ,
175+ userPrompt: String ,
176+ configuration: TextPostProcessingConfiguration ,
177+ timeout: TimeInterval ,
178+ maxTokens: Int
179+ ) throws -> String {
180+ let configuredPath = expandedPath ( configuration. runnerPath)
181+ guard !configuredPath. isEmpty else { throw TextPostProcessingError . missingRunnerPath }
182+ guard let runnerPath = serverExecutable ( for: configuredPath) else {
183+ throw TextPostProcessingError . runnerNotExecutable ( configuredPath)
184+ }
185+
186+ let socketURL = URL ( fileURLWithPath: " /tmp " )
187+ . appendingPathComponent ( " vocamac- \( UUID ( ) . uuidString) .sock " )
188+ let server = Process ( )
189+ let serverOutput = Pipe ( )
190+ let serverFinished = DispatchSemaphore ( value: 0 )
191+ let serverOutputRead = DispatchSemaphore ( value: 0 )
192+ var serverOutputData = Data ( )
193+
194+ server. executableURL = URL ( fileURLWithPath: runnerPath)
195+ server. arguments = ( try modelArguments ( for: configuration) ) + [
196+ " --host " , socketURL. path,
197+ " -c " , " 2048 " ,
198+ " --reasoning " , " off " ,
199+ ]
200+ server. standardInput = FileHandle . nullDevice
201+ server. standardOutput = serverOutput
202+ server. standardError = serverOutput
203+ server. terminationHandler = { _ in serverFinished. signal ( ) }
204+
205+ try server. run ( )
206+
207+ DispatchQueue . global ( qos: . utility) . async {
208+ serverOutputData = serverOutput. fileHandleForReading. readDataToEndOfFile ( )
209+ serverOutputRead. signal ( )
210+ }
178211
179- let promptMarker = " > \( userPrompt) "
180- if let range = text. range ( of: promptMarker) {
181- text = String ( text [ range. upperBound... ] )
212+ defer {
213+ if server. isRunning {
214+ server. terminate ( )
215+ }
216+ _ = serverFinished. wait ( timeout: . now( ) + 5 )
217+ try ? FileManager . default. removeItem ( at: socketURL)
182218 }
183219
184- text = text. replacingOccurrences (
185- of: #"(?s)<think>.*?</think>"# ,
186- with: " " ,
187- options: . regularExpression
220+ let deadline = Date ( ) . addingTimeInterval ( timeout)
221+ try waitUntilReady (
222+ server: server,
223+ socketURL: socketURL,
224+ deadline: deadline,
225+ outputRead: serverOutputRead,
226+ outputData: { serverOutputData }
188227 )
189- text = text. replacingOccurrences (
190- of: #"(?m)^\[ Prompt:.*$"# ,
191- with: " " ,
192- options: . regularExpression
228+
229+ let request = ChatCompletionRequest (
230+ messages: [
231+ ChatMessage ( role: " system " , content: systemPrompt) ,
232+ ChatMessage ( role: " user " , content: userPrompt) ,
233+ ] ,
234+ maxTokens: maxTokens,
235+ temperature: 0.1 ,
236+ topP: 0.8 ,
237+ stream: false
193238 )
194- text = text. replacingOccurrences (
195- of: #"(?m)^Exiting\.\.\.$"# ,
196- with: " " ,
197- options: . regularExpression
239+ let requestData = try JSONEncoder ( ) . encode ( request)
240+ let remaining = deadline. timeIntervalSinceNow
241+ guard remaining > 0 else { throw TextPostProcessingError . timedOut }
242+
243+ let responseData = try runCurl (
244+ arguments: [
245+ " --silent " ,
246+ " --show-error " ,
247+ " --fail-with-body " ,
248+ " --max-time " , " \( Int ( ceil ( remaining) ) ) " ,
249+ " --unix-socket " , socketURL. path,
250+ " --header " , " Content-Type: application/json " ,
251+ " --data-binary " , " @- " ,
252+ " http://localhost/v1/chat/completions " ,
253+ ] ,
254+ input: requestData,
255+ timeout: remaining
198256 )
199- return text . trimmingCharacters ( in : . whitespacesAndNewlines )
257+ return try responseText ( from : responseData )
200258 }
201259
202- private static func run(
203- systemPrompt: String ,
204- userPrompt: String ,
205- configuration: TextPostProcessingConfiguration ,
206- timeout: TimeInterval ,
207- maxTokens: Int
208- ) throws -> String {
209- let runnerPath = expandedPath ( configuration. runnerPath)
260+ static func responseText( from data: Data ) throws -> String {
261+ do {
262+ let response = try JSONDecoder ( ) . decode ( ChatCompletionResponse . self, from: data)
263+ let text = response. choices. first? . message. content
264+ . trimmingCharacters ( in: . whitespacesAndNewlines) ?? " "
265+ guard !text. isEmpty else { throw TextPostProcessingError . emptyOutput }
266+ return text
267+ } catch let error as TextPostProcessingError {
268+ throw error
269+ } catch {
270+ throw TextPostProcessingError . invalidResponse ( error. localizedDescription)
271+ }
272+ }
210273
211- guard !runnerPath. isEmpty else { throw TextPostProcessingError . missingRunnerPath }
212- guard FileManager . default. isExecutableFile ( atPath: runnerPath) else {
213- throw TextPostProcessingError . runnerNotExecutable ( runnerPath)
274+ private static func waitUntilReady(
275+ server: Process ,
276+ socketURL: URL ,
277+ deadline: Date ,
278+ outputRead: DispatchSemaphore ,
279+ outputData: ( ) -> Data
280+ ) throws {
281+ while Date ( ) < deadline {
282+ guard server. isRunning else {
283+ _ = outputRead. wait ( timeout: . now( ) + 1 )
284+ let details = String ( data: outputData ( ) , encoding: . utf8) ?
285+ . trimmingCharacters ( in: . whitespacesAndNewlines) ?? " "
286+ throw TextPostProcessingError . processFailed ( server. terminationStatus, details)
287+ }
288+
289+ if FileManager . default. fileExists ( atPath: socketURL. path) ,
290+ ( try ? runCurl (
291+ arguments: [
292+ " --silent " ,
293+ " --fail " ,
294+ " --max-time " , " 1 " ,
295+ " --unix-socket " , socketURL. path,
296+ " http://localhost/health " ,
297+ ] ,
298+ timeout: 2
299+ ) ) != nil {
300+ return
301+ }
302+ Thread . sleep ( forTimeInterval: 0.1 )
214303 }
304+ throw TextPostProcessingError . timedOut
305+ }
215306
307+ private static func runCurl(
308+ arguments: [ String ] ,
309+ input: Data ? = nil ,
310+ timeout: TimeInterval
311+ ) throws -> Data {
216312 let task = Process ( )
217- let outputPipe = Pipe ( )
313+ let output = Pipe ( )
314+ let inputPipe = input. map { _ in Pipe ( ) }
218315 let finished = DispatchSemaphore ( value: 0 )
219316 let outputRead = DispatchSemaphore ( value: 0 )
220317 var outputData = Data ( )
221318
222- task. executableURL = URL ( fileURLWithPath: runnerPath)
223- task. arguments = runnerCommandPrefix ( for: runnerPath) + ( try modelArguments ( for: configuration) ) + [
224- " -sys " , systemPrompt,
225- " -p " , userPrompt,
226- " -n " , " \( maxTokens) " ,
227- " -c " , " 2048 " ,
228- " --temp " , " 0.1 " ,
229- " --top-k " , " 20 " ,
230- " --top-p " , " 0.8 " ,
231- " --simple-io " ,
232- " --no-display-prompt " ,
233- " -st " ,
234- " --reasoning " , " off " ,
235- ]
236- task. standardInput = FileHandle . nullDevice
237- task. standardOutput = outputPipe
238- task. standardError = outputPipe
319+ task. executableURL = URL ( fileURLWithPath: " /usr/bin/curl " )
320+ task. arguments = arguments
321+ task. standardInput = inputPipe ?? FileHandle . nullDevice
322+ task. standardOutput = output
323+ task. standardError = output
239324 task. terminationHandler = { _ in finished. signal ( ) }
240325
241326 try task. run ( )
242-
243327 DispatchQueue . global ( qos: . utility) . async {
244- outputData = outputPipe . fileHandleForReading. readDataToEndOfFile ( )
328+ outputData = output . fileHandleForReading. readDataToEndOfFile ( )
245329 outputRead. signal ( )
246330 }
331+ if let input, let inputPipe {
332+ inputPipe. fileHandleForWriting. write ( input)
333+ inputPipe. fileHandleForWriting. closeFile ( )
334+ }
247335
248336 guard finished. wait ( timeout: . now( ) + timeout) == . success else {
249337 task. terminate ( )
250338 throw TextPostProcessingError . timedOut
251339 }
252-
253340 outputRead. wait ( )
254- let output = String ( data : outputData , encoding : . utf8 ) ?? " "
341+
255342 guard task. terminationStatus == 0 else {
256- throw TextPostProcessingError . processFailed (
257- task. terminationStatus,
258- output. trimmingCharacters ( in: . whitespacesAndNewlines)
259- )
343+ let details = String ( data: outputData, encoding: . utf8) ?
344+ . trimmingCharacters ( in: . whitespacesAndNewlines) ?? " "
345+ throw TextPostProcessingError . processFailed ( task. terminationStatus, details)
260346 }
261-
262- let cleaned = cleanedOutput ( output, userPrompt: userPrompt)
263- guard !cleaned. isEmpty else { throw TextPostProcessingError . emptyOutput }
264- return cleaned
347+ return outputData
265348 }
266349
267350 static func modelArguments( for configuration: TextPostProcessingConfiguration ) throws -> [ String ] {
@@ -283,6 +366,22 @@ final class LocalLLMPostProcessor: TextPostProcessing {
283366 . replacingOccurrences ( of: #"^~(?=/|$)"# , with: NSHomeDirectory ( ) , options: . regularExpression)
284367 }
285368
369+ private static func serverExecutable( for configuredPath: String ) -> String ? {
370+ let path = expandedPath ( configuredPath)
371+ guard !path. isEmpty else { return nil }
372+
373+ if URL ( fileURLWithPath: path) . lastPathComponent == " llama-server " ,
374+ FileManager . default. isExecutableFile ( atPath: path) {
375+ return path
376+ }
377+
378+ let sibling = URL ( fileURLWithPath: path)
379+ . deletingLastPathComponent ( )
380+ . appendingPathComponent ( " llama-server " )
381+ . path
382+ return FileManager . default. isExecutableFile ( atPath: sibling) ? sibling : nil
383+ }
384+
286385 private static func installLlamaCpp( using brewPath: String ) throws -> String {
287386 let task = Process ( )
288387 let finished = DispatchSemaphore ( value: 0 )
@@ -303,13 +402,37 @@ final class LocalLLMPostProcessor: TextPostProcessing {
303402 throw TextPostProcessingError . processFailed ( task. terminationStatus, " " )
304403 }
305404 guard let runner = detectedRunnerPath ( ) else {
306- throw TextPostProcessingError . runnerNotExecutable ( " llama.cpp installed, but no llama runner was found. " )
405+ throw TextPostProcessingError . runnerNotExecutable ( " llama.cpp installed, but llama-server was not found. " )
307406 }
308407 return runner
309408 }
310409
311- private static func runnerCommandPrefix( for runnerPath: String ) -> [ String ] {
312- URL ( fileURLWithPath: runnerPath) . lastPathComponent == " llama " ? [ " cli " ] : [ ]
410+ private struct ChatCompletionRequest : Encodable {
411+ let messages : [ ChatMessage ]
412+ let maxTokens : Int
413+ let temperature : Double
414+ let topP : Double
415+ let stream : Bool
416+
417+ enum CodingKeys : String , CodingKey {
418+ case messages
419+ case maxTokens = " max_tokens "
420+ case temperature
421+ case topP = " top_p "
422+ case stream
423+ }
424+ }
425+
426+ private struct ChatMessage : Codable {
427+ let role : String
428+ let content : String
313429 }
314430
431+ private struct ChatCompletionResponse : Decodable {
432+ let choices : [ Choice ]
433+
434+ struct Choice : Decodable {
435+ let message : ChatMessage
436+ }
437+ }
315438}
0 commit comments