From 704746803a4b97b068253c1d5edd635571c86c57 Mon Sep 17 00:00:00 2001 From: julio4 <30329843+julio4@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:41:58 +0900 Subject: [PATCH] local: configurable concurrent frame transcription Describe screenshots with up to llmLocalMaxConcurrency describe_frame calls in flight against the local server (Ollama/LM Studio), instead of strictly one at a time. New setting defaults to 1 (sequential); a Max concurrent requests stepper is added to the local provider settings. The server's concurrency limit isn't exposed over HTTP, so the value is user-set. --- .../AI/OllamaProvider+Transcription.swift | 46 +++++++++++++++++-- Dayflow/Dayflow/Core/AI/OllamaProvider.swift | 12 ++++- .../Settings/ProvidersSettingsViewModel.swift | 10 ++++ .../Settings/SettingsProvidersTabView.swift | 8 ++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/Dayflow/Dayflow/Core/AI/OllamaProvider+Transcription.swift b/Dayflow/Dayflow/Core/AI/OllamaProvider+Transcription.swift index cfc86c53e..553836754 100644 --- a/Dayflow/Dayflow/Core/AI/OllamaProvider+Transcription.swift +++ b/Dayflow/Dayflow/Core/AI/OllamaProvider+Transcription.swift @@ -65,6 +65,15 @@ extension OllamaProvider { } } + private func describeFrameResult(_ frame: FrameData, batchId: Int64?) async -> ( + TimeInterval, String + )? { + guard let description = await getSimpleFrameDescription(frame, batchId: batchId) else { + return nil + } + return (frame.timestamp, description) + } + private func parseVideoTimestamp(_ timestamp: String) -> Int { let components = timestamp.components(separatedBy: ":") @@ -474,20 +483,47 @@ extension OllamaProvider { let lastTs = sampledScreenshots.last!.capturedAt let durationSeconds = TimeInterval(lastTs - firstTs) - // Describe each screenshot - var frameDescriptions: [(timestamp: TimeInterval, description: String)] = [] - + // Describe each screenshot. Loading is cheap CPU work done sequentially; the + // describe calls are the slow, independent part, so run up to `maxConcurrency` + // of them in flight against the local server. Completion order is + // non-deterministic, so results are re-sorted by timestamp afterward. + var frames: [FrameData] = [] for screenshot in sampledScreenshots { guard let frameData = loadScreenshotAsFrameData(screenshot, relativeTo: firstTs) else { print("[OLLAMA] ⚠️ Failed to load screenshot: \(screenshot.filePath)") continue } + frames.append(frameData) + } - if let description = await getSimpleFrameDescription(frameData, batchId: batchId) { - frameDescriptions.append((timestamp: frameData.timestamp, description: description)) + let concurrency = max(1, maxConcurrency) + var frameDescriptions = await withTaskGroup( + of: (TimeInterval, String)?.self, + returning: [(timestamp: TimeInterval, description: String)].self + ) { group in + var cursor = 0 + while cursor < frames.count && cursor < concurrency { + let frameData = frames[cursor] + group.addTask { await self.describeFrameResult(frameData, batchId: batchId) } + cursor += 1 } + + var collected: [(timestamp: TimeInterval, description: String)] = [] + while let result = await group.next() { + if let (timestamp, description) = result { + collected.append((timestamp: timestamp, description: description)) + } + if cursor < frames.count { + let frameData = frames[cursor] + group.addTask { await self.describeFrameResult(frameData, batchId: batchId) } + cursor += 1 + } + } + return collected } + frameDescriptions.sort { $0.timestamp < $1.timestamp } + guard !frameDescriptions.isEmpty else { throw NSError( domain: "OllamaProvider", diff --git a/Dayflow/Dayflow/Core/AI/OllamaProvider.swift b/Dayflow/Dayflow/Core/AI/OllamaProvider.swift index 65eb1ee01..f1f4dde85 100644 --- a/Dayflow/Dayflow/Core/AI/OllamaProvider.swift +++ b/Dayflow/Dayflow/Core/AI/OllamaProvider.swift @@ -6,7 +6,7 @@ import AppKit import Foundation -final class OllamaProvider { +final class OllamaProvider: @unchecked Sendable { let endpoint: String let screenshotInterval: TimeInterval = 10 // seconds between screenshots // Read persisted local settings @@ -36,6 +36,16 @@ final class OllamaProvider { UserDefaults.standard.string(forKey: "llmLocalEngine") ?? "ollama" } + // How many describe_frame requests to run against the local server at once. + // The server caps this itself (LM Studio "Max Concurrent Predictions", Ollama + // OLLAMA_NUM_PARALLEL) and doesn't expose the limit over HTTP, so keep this ≤ + // that value. Default 1 (sequential). Override via + // `defaults write teleportlabs.com.Dayflow llmLocalMaxConcurrency `. + var maxConcurrency: Int { + let configured = UserDefaults.standard.integer(forKey: "llmLocalMaxConcurrency") + return configured > 0 ? min(configured, 16) : 1 + } + init(endpoint: String = "http://localhost:1234") { self.endpoint = endpoint } diff --git a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift index d128ef011..6092a6f53 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift @@ -49,6 +49,12 @@ final class ProvidersSettingsViewModel: ObservableObject { persistLocalAPIKey(localAPIKey) } } + @Published var localMaxConcurrency: Int { + didSet { + guard oldValue != localMaxConcurrency else { return } + UserDefaults.standard.set(localMaxConcurrency, forKey: "llmLocalMaxConcurrency") + } + } @Published var showLocalModelUpgradeBanner = false @Published var isShowingLocalModelUpgradeSheet = false @Published var upgradeStatusMessage: String? @@ -136,6 +142,8 @@ final class ProvidersSettingsViewModel: ObservableObject { } localAPIKey = UserDefaults.standard.string(forKey: "llmLocalAPIKey") ?? "" + let storedConcurrency = UserDefaults.standard.integer(forKey: "llmLocalMaxConcurrency") + localMaxConcurrency = storedConcurrency > 0 ? min(storedConcurrency, 16) : 1 if let raw = UserDefaults.standard.string(forKey: "chatCLIPreferredTool") { preferredCLITool = CLITool(rawValue: raw) } else { @@ -189,6 +197,8 @@ final class ProvidersSettingsViewModel: ObservableObject { localBaseURL = UserDefaults.standard.string(forKey: "llmLocalBaseURL") ?? localBaseURL localModelId = UserDefaults.standard.string(forKey: "llmLocalModelId") ?? localModelId localAPIKey = UserDefaults.standard.string(forKey: "llmLocalAPIKey") ?? localAPIKey + let storedConcurrency = UserDefaults.standard.integer(forKey: "llmLocalMaxConcurrency") + localMaxConcurrency = storedConcurrency > 0 ? min(storedConcurrency, 16) : 1 let raw = UserDefaults.standard.string(forKey: "llmLocalEngine") ?? localEngine.rawValue localEngine = LocalEngine(rawValue: raw) ?? localEngine LocalModelPreferences.syncPreset(for: localEngine, modelId: localModelId) diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift index f5e851f9d..cbe77d121 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift @@ -97,6 +97,14 @@ struct SettingsProvidersTabView: View { text: viewModel.localModelId.isEmpty ? "Not configured" : viewModel.localModelId) } SettingsRow(label: "Endpoint") { SettingsMetadata(text: viewModel.localBaseURL) } + SettingsRow(label: "Max concurrent requests") { + HStack(spacing: 8) { + SettingsMetadata(text: "\(viewModel.localMaxConcurrency)") + Stepper("", value: $viewModel.localMaxConcurrency, in: 1...16) + .labelsHidden() + .fixedSize() + } + } let hasKey = !viewModel.localAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty SettingsRow(label: "API key", showsDivider: false) { SettingsMetadata(text: hasKey ? "Stored in UserDefaults" : "Not set")