From e6e5c74a051cbe0550f2828808b1c1768fc850df Mon Sep 17 00:00:00 2001 From: M3NT1 Date: Sun, 19 Jul 2026 20:42:53 +0200 Subject: [PATCH 1/3] feat: add MiniMax M3 as a cloud AI provider (v2.0.3 base) Ports the MiniMax integration from #320 onto the v2.0.3 base, which refactored the provider system into LLMProviderID with separate cases for chatGPT, claude, openAICompatible, and local. What's included - New LLMProviderID.minimax case with analytics + display label - MiniMaxProvider + 4 extension files (Networking, Reasoning, Summaries, Transcription) following the OllamaProvider pattern but using Bearer auth against https://api.minimax.io/v1 with M3's 1M-token context - MiniMaxModelPreference, MiniMaxPromptPreferences, MiniMaxAPIHelper - LLMService.makeMiniMaxProvider() wired into makeBatchProvider and makeTextProvider; providerModelId(for:) helper stamps cards with the active model - Storage layer: provider_id + model_id columns on timeline_cards with ALTER TABLE migration, TimelineCard/TimelineCardShell fields, and fetchAllTimelineCards() for the dashboard - Onboarding: 6th provider card (sparkles icon) + dedicated 4-step setup flow (get-key, enter-key, test, complete) - Settings: MiniMax section in providers tab with model picker, keychain field, test-connection button, and prompt-override plumbing - ModelCatalog + ProviderStatsCalculator + ProviderStatsView (dashboard data + GitHub-style heatmap + daily/weekly/monthly breakdowns) Build verified: xcodebuild Release succeeds, no new errors vs v2.0.3 base --- .../Dayflow/Core/AI/DailyRecapModels.swift | 2 + Dayflow/Dayflow/Core/AI/LLMService.swift | 58 +- Dayflow/Dayflow/Core/AI/LLMTypes.swift | 5 + .../Core/AI/MiniMaxModelPreference.swift | 49 ++ .../Core/AI/MiniMaxPromptPreferences.swift | 108 ++++ .../Core/AI/MiniMaxProvider+Networking.swift | 296 ++++++++++ .../Core/AI/MiniMaxProvider+Reasoning.swift | 68 +++ .../Core/AI/MiniMaxProvider+Summaries.swift | 481 ++++++++++++++++ .../AI/MiniMaxProvider+Transcription.swift | 507 +++++++++++++++++ Dayflow/Dayflow/Core/AI/MiniMaxProvider.swift | 253 +++++++++ Dayflow/Dayflow/Core/AI/ModelCatalog.swift | 517 ++++++++++++++++++ .../Analytics/ProviderStatsCalculator.swift | 209 +++++++ .../StorageManager+TimelineCards.swift | 88 ++- .../Core/Recording/StorageManager.swift | 27 + .../Core/Recording/StorageManaging.swift | 1 + .../Core/Recording/StorageModels.swift | 16 +- .../Dayflow/Utilities/MiniMaxAPIHelper.swift | 94 ++++ .../Onboarding/LLMProviderSetupView.swift | 2 + ...nboardingPrototypeChooseProviderStep.swift | 10 + .../Views/Onboarding/ProviderSetupState.swift | 26 + .../UI/Dashboard/ProviderStatsView.swift | 505 +++++++++++++++++ ...ersSettingsViewModel+PromptOverrides.swift | 10 +- .../Settings/ProvidersSettingsViewModel.swift | 47 ++ .../Settings/SettingsProvidersTabView.swift | 29 +- 24 files changed, 3390 insertions(+), 18 deletions(-) create mode 100644 Dayflow/Dayflow/Core/AI/MiniMaxModelPreference.swift create mode 100644 Dayflow/Dayflow/Core/AI/MiniMaxPromptPreferences.swift create mode 100644 Dayflow/Dayflow/Core/AI/MiniMaxProvider+Networking.swift create mode 100644 Dayflow/Dayflow/Core/AI/MiniMaxProvider+Reasoning.swift create mode 100644 Dayflow/Dayflow/Core/AI/MiniMaxProvider+Summaries.swift create mode 100644 Dayflow/Dayflow/Core/AI/MiniMaxProvider+Transcription.swift create mode 100644 Dayflow/Dayflow/Core/AI/MiniMaxProvider.swift create mode 100644 Dayflow/Dayflow/Core/AI/ModelCatalog.swift create mode 100644 Dayflow/Dayflow/Core/Analytics/ProviderStatsCalculator.swift create mode 100644 Dayflow/Dayflow/Utilities/MiniMaxAPIHelper.swift create mode 100644 Dayflow/Dayflow/Views/UI/Dashboard/ProviderStatsView.swift diff --git a/Dayflow/Dayflow/Core/AI/DailyRecapModels.swift b/Dayflow/Dayflow/Core/AI/DailyRecapModels.swift index ecd5069b2..71929e4ed 100644 --- a/Dayflow/Dayflow/Core/AI/DailyRecapModels.swift +++ b/Dayflow/Dayflow/Core/AI/DailyRecapModels.swift @@ -56,6 +56,8 @@ enum DailyRecapProvider: String, Codable, CaseIterable, Sendable { return .local case .openAICompatible: return .none + case .minimax: + return .none } } diff --git a/Dayflow/Dayflow/Core/AI/LLMService.swift b/Dayflow/Dayflow/Core/AI/LLMService.swift index 00e274c38..4e9468184 100644 --- a/Dayflow/Dayflow/Core/AI/LLMService.swift +++ b/Dayflow/Dayflow/Core/AI/LLMService.swift @@ -154,10 +154,42 @@ final class LLMService: LLMServicing { return OllamaProvider(openAICompatible: runtimeConfiguration) } + private func makeMiniMaxProvider() -> MiniMaxProvider? { + guard let key = KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey), + !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + print("❌ [LLMService] MiniMax provider unavailable: missing API key") + return nil + } + return MiniMaxProvider() + } + private func providerLabel(for providerID: LLMProviderID) -> String { providerID.providerLabel } + /// Returns the model ID the provider should be stamped with on generated + /// cards. Falls back to the canonical provider ID when the user hasn't + /// chosen a specific model yet. + private func providerModelId(for providerID: LLMProviderID) -> String? { + switch providerID { + case .gemini: + return GeminiModelPreference.load().primary.rawValue + case .local: + return UserDefaults.standard.string(forKey: "llmLocalModelId") + case .openAICompatible: + return OpenAICompatiblePreferences.load()?.modelID + case .chatGPT: + return UserDefaults.standard.string(forKey: "llmCodexModel") + case .claude: + return UserDefaults.standard.string(forKey: "llmClaudeModel") + case .minimax: + return MiniMaxModelPreference.load().modelId + case .dayflow: + return nil + } + } + private func noProviderError() -> NSError { NSError( domain: "LLMService", @@ -261,6 +293,20 @@ final class LLMService: LLMServicing { generateActivityCards: provider.generateActivityCards ), fallbackState: nil ) + case .minimax: + guard let provider = makeMiniMaxProvider() else { throw noProviderError() } + return ( + actions: BatchProviderActions( + transcribeScreenshots: { [provider] screenshots, batchStartTime, batchId in + try await provider.transcribeScreenshots( + screenshots, batchStartTime: batchStartTime, batchId: batchId) + }, + generateActivityCards: { [provider] observations, context, batchId in + try await provider.generateActivityCards( + observations: observations, context: context, batchId: batchId) + } + ), fallbackState: nil + ) } } @@ -555,6 +601,14 @@ final class LLMService: LLMServicing { }, generateTextStreaming: provider.generateTextStreaming ) + case .minimax: + guard let provider = makeMiniMaxProvider() else { throw noProviderError() } + return TextProviderActions( + generateText: { prompt in + try await provider.generateText(prompt: prompt) + }, + generateTextStreaming: nil + ) } } @@ -853,7 +907,9 @@ final class LLMService: LLMServicing { detailedSummary: card.detailedSummary, distractions: card.distractions, appSites: card.appSites, - isBackupGenerated: isBackupGenerated ? true : nil + isBackupGenerated: isBackupGenerated ? true : nil, + providerId: activeContext.id.providerLabel, + modelId: providerModelId(for: activeContext.id) ) }, batchId: batchId diff --git a/Dayflow/Dayflow/Core/AI/LLMTypes.swift b/Dayflow/Dayflow/Core/AI/LLMTypes.swift index 6b6abb9cf..375b7186b 100644 --- a/Dayflow/Dayflow/Core/AI/LLMTypes.swift +++ b/Dayflow/Dayflow/Core/AI/LLMTypes.swift @@ -87,6 +87,7 @@ enum LLMProviderID: String, Codable, CaseIterable { case claude case openAICompatible = "openai_compatible" case local + case minimax var analyticsName: String { switch self { @@ -100,6 +101,8 @@ enum LLMProviderID: String, Codable, CaseIterable { return "openai_compatible" case .local: return "ollama" + case .minimax: + return "minimax" } } @@ -112,6 +115,8 @@ enum LLMProviderID: String, Codable, CaseIterable { case .openAICompatible: return "openai_compatible" case .local: return "local" + case .minimax: + return "minimax" } } } diff --git a/Dayflow/Dayflow/Core/AI/MiniMaxModelPreference.swift b/Dayflow/Dayflow/Core/AI/MiniMaxModelPreference.swift new file mode 100644 index 000000000..bfbf54de2 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/MiniMaxModelPreference.swift @@ -0,0 +1,49 @@ +// +// MiniMaxModelPreference.swift +// Dayflow +// +// Persisted preference for the active MiniMax model. Mirrors the shape of +// `GeminiModelPreference` so the settings UI can drop the same component in. +// + +import Foundation + +struct MiniMaxModelPreference: Codable, Equatable { + /// Wire-format model id sent in chat completion requests. + var modelId: String + + /// Whether the user has selected a non-default model. + var hasUserOverride: Bool + + static let `default` = MiniMaxModelPreference( + modelId: MiniMaxProvider.defaultModelId, + hasUserOverride: false + ) + + static let storageKey = "minimaxModelPreference" + + static func load(from defaults: UserDefaults = .standard) -> MiniMaxModelPreference { + guard let data = defaults.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode(MiniMaxModelPreference.self, from: data) + else { + return .default + } + return decoded + } + + func persist(to defaults: UserDefaults = .standard) { + guard let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: Self.storageKey) + } + + mutating func setModelId(_ newValue: String) { + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.modelId = trimmed + self.hasUserOverride = (trimmed != MiniMaxProvider.defaultModelId) + } + + mutating func reset() { + self = .default + } +} diff --git a/Dayflow/Dayflow/Core/AI/MiniMaxPromptPreferences.swift b/Dayflow/Dayflow/Core/AI/MiniMaxPromptPreferences.swift new file mode 100644 index 000000000..1300c25f6 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/MiniMaxPromptPreferences.swift @@ -0,0 +1,108 @@ +// +// MiniMaxPromptPreferences.swift +// Dayflow +// +// User-overridable prompt blocks for the MiniMax provider. Defaults are +// intentionally aligned with the Ollama prompt defaults so behaviour stays +// consistent across providers. Users can customise either block independently. +// + +import Foundation + +struct MiniMaxPromptOverrides: Codable, Equatable { + var summaryBlock: String? + var titleBlock: String? + + var isEmpty: Bool { + [summaryBlock, titleBlock].allSatisfy { value in + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty + } + } +} + +enum MiniMaxPromptPreferences { + private static let overridesKey = "minimaxPromptOverrides" + private static let store = UserDefaults.standard + + static func load() -> MiniMaxPromptOverrides { + guard let data = store.data(forKey: overridesKey) else { + return MiniMaxPromptOverrides() + } + guard let overrides = try? JSONDecoder().decode(MiniMaxPromptOverrides.self, from: data) + else { + return MiniMaxPromptOverrides() + } + return overrides + } + + static func save(_ overrides: MiniMaxPromptOverrides) { + guard let data = try? JSONEncoder().encode(overrides) else { return } + store.set(data, forKey: overridesKey) + } + + static func reset() { + store.removeObject(forKey: overridesKey) + } +} + +enum MiniMaxPromptDefaults { + static let summaryBlock = """ + SUMMARY GUIDELINES: + - Write in first person without using "I" (like a personal journal entry) + - 2-3 sentences maximum + - Include specific details (app names, search topics, etc.) + - Natural, conversational tone + + GOOD EXAMPLES: + "Managed Mac system preferences focusing on software updates and accessibility settings. Browsed Chrome searching for iPhone wireless charging info while + checking Twitter and Slack messages." + + "Configured GitHub Actions pipeline for automated testing. Quick Slack check interrupted focus, then back to debugging deployment issues." + + "Researched React performance optimization techniques in Chrome, reading articles about useMemo patterns. Switched between documentation tabs and took notes in + Notion about component re-rendering." + + "Updated Xcode project dependencies and resolved build errors in SwiftUI views. Tested app on simulator while responding to client messages about timeline + changes." + + "Browsed Instagram and TikTok while listening to Spotify playlist. Responded to personal messages on WhatsApp about weekend plans." + + BAD EXAMPLES: + - "The user did various computer activities" (too vague, wrong perspective, never say the user) + - "I was working on my computer doing different tasks" (uses "I", not specific) + - "Spent time on multiple applications and websites" (generic, no details) + """ + + static let titleBlock = """ + Write one activity title for a 15-minute window using ONLY the observations. + Rules: + - 5-10 words, natural and specific, single line + - Choose the dominant activity (most time), not necessarily the first + - Ignore brief interruptions (<3 minutes) + - Include a second activity only if both take ~5+ minutes + - If 3+ unrelated activities appear, output exactly: "Scattered apps and sites" + - Prefer proper nouns/topics (Bookface, Claude, League of Legends, Paul Graham, etc.) + - Never use: worked on, looked at, handled, various, some, multiple, browsing, browse, multitasking, tabs, brief, quick, short + - Do NOT use the word "browsing"; use "scrolling" or "reading" instead + - Avoid long lists; no more than one conjunction + - Return only the title text (no quotes, no JSON) + """ +} + +struct MiniMaxPromptSections { + let summary: String + let title: String + + init(overrides: MiniMaxPromptOverrides) { + self.summary = MiniMaxPromptSections.compose( + defaultBlock: MiniMaxPromptDefaults.summaryBlock, custom: overrides.summaryBlock) + self.title = MiniMaxPromptSections.compose( + defaultBlock: MiniMaxPromptDefaults.titleBlock, custom: overrides.titleBlock) + } + + private static func compose(defaultBlock: String, custom: String?) -> String { + let trimmed = custom?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? defaultBlock : trimmed + } +} diff --git a/Dayflow/Dayflow/Core/AI/MiniMaxProvider+Networking.swift b/Dayflow/Dayflow/Core/AI/MiniMaxProvider+Networking.swift new file mode 100644 index 000000000..4d3c2dbf9 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/MiniMaxProvider+Networking.swift @@ -0,0 +1,296 @@ +// +// MiniMaxProvider+Networking.swift +// Dayflow +// +// HTTP client for the MiniMax OpenAI-compatible API. +// + +import Foundation + +extension MiniMaxProvider { + struct ChatRequest: Codable { + let model: String + let messages: [ChatMessage] + var temperature: Double = 0.7 + var max_tokens: Int = 4000 + var stream: Bool = false + } + + struct ChatMessage: Codable { + let role: String + let content: [MessageContent] + } + + struct MessageContent: Codable { + let type: String + let text: String? + let image_url: ImageURL? + + struct ImageURL: Codable { + let url: String + } + } + + struct ChatResponse: Codable { + let choices: [Choice] + + struct Choice: Codable { + let message: ResponseMessage + } + + struct ResponseMessage: Codable { + let content: String + } + } + + // MARK: - Chat completions + + func callChatAPI( + _ request: ChatRequest, + operation: String, + batchId: Int64? = nil, + maxRetries: Int = 3 + ) async throws -> ChatResponse { + guard let url = LocalEndpointUtilities.chatCompletionsURL(baseURL: endpoint) else { + throw NSError( + domain: "MiniMaxProvider", code: 15, + userInfo: [NSLocalizedDescriptionKey: "Invalid MiniMax endpoint URL"]) + } + + let attempts = max(1, maxRetries) + var lastError: Error? + + let callGroupId = UUID().uuidString + for attempt in 0.. String { + let systemPrompt = + expectJSON + ? "You are a helpful assistant. Always respond with valid JSON." + : "You are a helpful assistant." + + let request = ChatRequest( + model: savedModelId, + messages: [ + ChatMessage( + role: "system", + content: [MessageContent(type: "text", text: systemPrompt, image_url: nil)]), + ChatMessage( + role: "user", content: [MessageContent(type: "text", text: prompt, image_url: nil)]), + ], + temperature: 0.7, + max_tokens: maxTokens, + stream: false + ) + + let response = try await callChatAPI( + request, operation: operation, batchId: batchId, maxRetries: maxRetries) + let raw = response.choices.first?.message.content ?? "" + return MiniMaxReasoning.stripThinkTags(in: raw) + } + + private func applyAuthorizationHeader(to request: inout URLRequest) { + if let key = apiKey { + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + } + } +} + +// MARK: - Text Generation + +extension MiniMaxProvider { + func generateText(prompt: String, maxTokens: Int = 4000) async throws + -> (text: String, log: LLMCall) + { + let callStart = Date() + + let response = try await callTextAPI( + prompt, + operation: "generate_text", + expectJSON: false, + batchId: nil, + maxRetries: 3, + maxTokens: maxTokens + ) + + let log = LLMCall( + timestamp: callStart, + latency: Date().timeIntervalSince(callStart), + input: prompt, + output: response + ) + + return (response.trimmingCharacters(in: .whitespacesAndNewlines), log) + } +} diff --git a/Dayflow/Dayflow/Core/AI/MiniMaxProvider+Reasoning.swift b/Dayflow/Dayflow/Core/AI/MiniMaxProvider+Reasoning.swift new file mode 100644 index 000000000..942013503 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/MiniMaxProvider+Reasoning.swift @@ -0,0 +1,68 @@ +// +// MiniMaxProvider+Reasoning.swift +// Dayflow +// +// MiniMax M3 emits `...` reasoning blocks inside the +// `content` field of chat completions responses. We strip those before +// parsing/returning the answer so the downstream JSON parsers and UI +// text consumers don't have to care about them. +// + +import Foundation + +enum MiniMaxReasoning { + /// Removes one or more `...` blocks from the model output. + /// Handles the case where reasoning is not closed (defensive). + static func stripThinkTags(in text: String) -> String { + guard !text.isEmpty else { return text } + + var result = "" + var remaining = text + + while let openRange = remaining.range(of: "") { + // Append everything before the open tag. + result += String(remaining[remaining.startIndex..") { + // Skip the entire reasoning block. + remaining = String(afterOpen[closeRange.upperBound...]) + } else { + // Unterminated think block — drop the rest. + remaining = "" + break + } + } + + result += remaining + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// If the response contains reasoning, return it separately from the answer. + /// Useful for logging or surfacing a "thinking" indicator in the UI. + static func split(_ text: String) -> (reasoning: String, answer: String) { + guard !text.isEmpty else { return ("", "") } + var reasoningPieces: [String] = [] + var remaining = text + var answer = "" + + while let openRange = remaining.range(of: "") { + answer += String(remaining[remaining.startIndex..") { + reasoningPieces.append(String(afterOpen[afterOpen.startIndex.. String { + let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed + var result = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) + + if result.hasPrefix("\""), result.hasSuffix("\""), result.count >= 2 { + result = String(result.dropFirst().dropLast()) + } + + let lowercased = result.lowercased() + if lowercased.hasPrefix("title:") { + let dropped = String(result.dropFirst("title:".count)) + result = dropped.trimmingCharacters(in: .whitespacesAndNewlines) + } + + return result + } + + private func buildAppSites(from response: SummaryResponse.AppSitesResponse?) -> AppSites? { + guard let response else { return nil } + let primary = response.primary?.trimmingCharacters(in: .whitespacesAndNewlines) + let secondary = response.secondary?.trimmingCharacters(in: .whitespacesAndNewlines) + + let cleanedPrimary = primary?.isEmpty == false ? primary : nil + let cleanedSecondary = secondary?.isEmpty == false ? secondary : nil + + if cleanedPrimary == nil && cleanedSecondary == nil { + return nil + } + + return AppSites(primary: cleanedPrimary, secondary: cleanedSecondary) + } + + private func generateSummary( + observations: [Observation], categories: [LLMCategoryDescriptor], batchId: Int64? + ) async throws -> (SummaryResponse, String) { + let observationLines: [String] = observations.map { obs in + let startTime = formatTimestampForPrompt(obs.startTs) + let endTime = formatTimestampForPrompt(obs.endTs) + return "[\(startTime) - \(endTime)]: \(obs.observation)" + } + let observationsText: String = stripUserReferences(observationLines.joined(separator: "\n\n")) + + let descriptorList = categories.isEmpty ? CategoryStore.descriptorsForLLM() : categories + let categoryLines: [String] = descriptorList.enumerated().map { index, descriptor in + var description = + descriptor.description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if descriptor.isIdle && description.isEmpty { + description = "Use when the user is idle for most of the period." + } + let dashDescription = description.isEmpty ? "" : " — \(description)" + return "- \"\(descriptor.name)\"\(dashDescription)" + } + let categoriesSection: String = categoryLines.joined(separator: "\n") + + let allowedValues: String = + descriptorList + .map { "\"\($0.name)\"" } + .joined(separator: ", ") + + let promptSections = MiniMaxPromptSections(overrides: MiniMaxPromptPreferences.load()) + + let languageBlock = + LLMOutputLanguagePreferences.languageInstruction(forJSON: true) + .map { "\n\n\($0)" } ?? "" + + let basePrompt = """ + You are analyzing someone's computer activity from the last 15 minutes. + + Activity periods: + \(observationsText) + + Create a summary that captures what happened during this time period. + + \(promptSections.summary) + + CATEGORIES: + Choose exactly one: + \(categoriesSection) + + APP SITES (Website Logos) + Identify the main app or website used for this period. Output the canonical DOMAIN, not the app name. + - primary: canonical domain of the main app/website used. + - secondary: another meaningful app used, if relevant. + - Format: lower-case, no protocol, no query or fragments. + - Use product subdomains/paths when canonical (e.g., docs.google.com). + - If you cannot determine a secondary, omit it. + - Do not invent brands; rely on evidence from observations. + + REASONING: + Explain your thinking process: + 1. What were the main activities and how much time was spent on each? + 2. Was this primarily work-related, personal, or brief distractions? + 3. Which category best fits based on the MAJORITY of time and focus? + 4. How did you structure the summary to capture the most important activities? + + \(languageBlock) + + Return JSON: + { + "reasoning": "Your step-by-step thinking process", + "summary": "Your 2-3 sentence summary", + "category": "\(allowedValues)", + "app_sites": {"primary": "domain.com", "secondary": "domain.com"} + } + """ + + let maxAttempts = 3 + var prompt = basePrompt + var lastError: Error? + + for attempt in 1...maxAttempts { + do { + let response = try await callTextAPI( + prompt, operation: "generate_summary", expectJSON: true, batchId: batchId) + + guard let data = response.data(using: .utf8) else { + throw NSError( + domain: "MiniMaxProvider", code: 12, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse summary response"]) + } + + let result = try parseJSONResponse(SummaryResponse.self, from: data) + return (result, response) + } catch { + lastError = error + if attempt == maxAttempts { throw error } + + print("[MINIMAX] ⚠️ generateSummary attempt \(attempt) failed: \(error.localizedDescription)") + + prompt = + basePrompt + """ + + + PREVIOUS ATTEMPT FAILED — The response was invalid (error: \(error.localizedDescription)). + Respond with ONLY the JSON object described above. Ensure it contains "reasoning", "summary", "category", and "app_sites" fields. + """ + } + } + + throw lastError + ?? NSError( + domain: "MiniMaxProvider", code: 12, + userInfo: [NSLocalizedDescriptionKey: "Failed to generate summary after multiple attempts"]) + } + + private func generateTitle(observations: [Observation], batchId: Int64?) async throws -> ( + TitleResponse, String + ) { + let promptSections = MiniMaxPromptSections(overrides: MiniMaxPromptPreferences.load()) + let languageBlock = + LLMOutputLanguagePreferences.languageInstruction(forJSON: false) + .map { "\n\n\($0)" } ?? "" + let observationsText = + observations + .map { $0.observation.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { "- \($0)" } + .joined(separator: "\n") + + let basePrompt = """ + \(promptSections.title) + \(languageBlock) + + OBSERVATIONS: + \(observationsText) + """ + + let maxAttempts = 3 + var prompt = basePrompt + var lastError: Error? + + for attempt in 1...maxAttempts { + do { + let response = try await callTextAPI( + prompt, operation: "generate_title", expectJSON: false, batchId: batchId) + let title = normalizeTitleText(response) + guard !title.isEmpty else { + throw NSError( + domain: "MiniMaxProvider", code: 12, + userInfo: [NSLocalizedDescriptionKey: "Empty title response"]) + } + let result = TitleResponse(reasoning: "Generated from observations.", title: title) + return (result, response) + } catch { + lastError = error + if attempt == maxAttempts { throw error } + print("[MINIMAX] ⚠️ generateTitle attempt \(attempt) failed: \(error.localizedDescription)") + prompt = + basePrompt + """ + + + PREVIOUS ATTEMPT FAILED — The response was invalid (error: \(error.localizedDescription)). + Respond with ONLY the title text on a single line. Do not include JSON or quotes. + """ + } + } + + throw lastError + ?? NSError( + domain: "MiniMaxProvider", code: 12, + userInfo: [NSLocalizedDescriptionKey: "Failed to generate title after multiple attempts"]) + } + + func generateTitleAndSummary( + observations: [Observation], categories: [LLMCategoryDescriptor], batchId: Int64? + ) async throws -> (TitleSummaryResponse, String) { + let (summaryResult, summaryLog) = try await generateSummary( + observations: observations, + categories: categories, + batchId: batchId + ) + + let (titleResult, titleLog) = try await generateTitle( + observations: observations, batchId: batchId) + + let appSites = buildAppSites(from: summaryResult.appSites) + + let combinedResult = TitleSummaryResponse( + reasoning: "Summary: \(summaryResult.reasoning) | Title: \(titleResult.reasoning)", + title: titleResult.title, + summary: summaryResult.summary, + category: summaryResult.category, + appSites: appSites + ) + + let combinedLog = + "=== SUMMARY GENERATION ===\n\(summaryLog)\n\n=== TITLE GENERATION ===\n\(titleLog)" + + return (combinedResult, combinedLog) + } + + func checkShouldMerge( + previousCard: ActivityCardData, newCard: ActivityCardData, batchId: Int64? + ) async throws -> (Bool, String) { + let basePrompt = """ + Decide if two consecutive activity cards should be merged. + + Previous activity (\(previousCard.startTime) - \(previousCard.endTime)): + Title: \(previousCard.title) + Summary: \(previousCard.summary) + + New activity (\(newCard.startTime) - \(newCard.endTime)): + Title: \(newCard.title) + Summary: \(newCard.summary) + + Merge ONLY if they clearly describe the same ongoing task or intent. + - Tool/app switches are allowed if they support the same goal (e.g., doc writing + research). + - Do NOT merge if there’s a context switch to a different intent (social feed, chat, video, gaming, email, shopping, unrelated reading). + - If unsure, do NOT merge. + + Return JSON only: + {"combine": true/false, "reason": "1 short sentence explaining the decision"} + + EXAMPLES (5): + + 1) MERGE + Prev: "Drafted onboarding doc in Google Docs. Looked up API details in the Stripe docs." + New: "Continued the onboarding doc, then cross-checked examples in Stripe docs." + → {"combine": true, "reason": "Same intent: onboarding doc + supporting research."} + + 2) MERGE + Prev: "Analyzed retention curves in Claude. Adjusted questions for clarity." + New: "Kept refining retention metrics in Claude and Notion." + → {"combine": true, "reason": "Same intent: retention analysis across tools."} + + 3) MERGE + Prev: "Fixed React auth bug in VS Code. Ran local tests." + New: "Validated the auth fix in Postman and added notes to the PR." + → {"combine": true, "reason": "Same task: auth fix and verification."} + + 4) DON'T MERGE + Prev: "Reviewed VC blog post on trohan.com." + New: "Watched League of Legends stream and chatted on Messenger." + → {"combine": false, "reason": "Different intent: research vs entertainment/chat."} + + 5) DON'T MERGE + Prev: "Drafted email reply about product launch." + New: "Scrolled X.com and watched a YouTube clip." + → {"combine": false, "reason": "Context switch to social/video."} + """ + + let maxAttempts = 3 + var prompt = basePrompt + var lastError: Error? + + for attempt in 1...maxAttempts { + do { + let response = try await callTextAPI( + prompt, operation: "evaluate_card_merge", expectJSON: true, batchId: batchId) + + guard let data = response.data(using: String.Encoding.utf8) else { + throw NSError( + domain: "MiniMaxProvider", code: 13, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse merge decision"]) + } + + let decision = try parseJSONResponse(MergeDecision.self, from: data) + return (decision.combine, response) + } catch { + lastError = error + if attempt == maxAttempts { throw error } + print("[MINIMAX] ⚠️ evaluate_card_merge attempt \(attempt) failed: \(error.localizedDescription)") + prompt = + basePrompt + """ + + + PREVIOUS ATTEMPT FAILED — The response was invalid (error: \(error.localizedDescription)). + Return ONLY the JSON object described above with "reason" and "combine" fields. + """ + } + } + + throw lastError + ?? NSError( + domain: "MiniMaxProvider", code: 13, + userInfo: [NSLocalizedDescriptionKey: "Failed to evaluate merge decision after multiple attempts"]) + } + + func mergeTwoCards( + previousCard: ActivityCardData, newCard: ActivityCardData, batchId: Int64? + ) async throws -> (ActivityCardData, String) { + let basePrompt = """ + Create a single activity card that covers both time periods. + + Activity 1 (\(previousCard.startTime) - \(previousCard.endTime)): + Title: \(previousCard.title) + Summary: \(previousCard.summary) + + Activity 2 (\(newCard.startTime) - \(newCard.endTime)): + Title: \(newCard.title) + Summary: \(newCard.summary) + + Create a unified title and summary that covers the entire period from \(previousCard.startTime) to \(newCard.endTime). + Title rules (use ONLY the titles and summaries above): + - 5-10 words, natural and specific, single line + - Choose the dominant activity (most time), not necessarily the first + - Ignore brief interruptions (<3 minutes) mentioned in the summaries + - Include a second activity only if both take ~5+ minutes + - If 3+ unrelated activities appear, output exactly: "Scattered apps and sites" + - Prefer proper nouns/topics (Bookface, Claude, League of Legends, Paul Graham, etc.) + - Never use: worked on, looked at, handled, various, some, multiple, browsing, browse, multitasking, tabs, brief, quick, short + - Do NOT use the word "browsing"; use "scrolling" or "reading" instead + - Avoid long lists; no more than one conjunction + - In the JSON below, the "title" field must contain only the title text (no extra labels or quotes) + Summary: Two sentences max, first-person perspective without using the word I. Retell how the work flowed from the first card into the second with concrete verbs (debugged, reviewed, watched) and name the stand-out tools/topics once each. Skip laundry lists, filler like “various tasks,” and bullet points. + Avoid the words social, media, platform, platforms, interaction, interactions, various, engaged, blend, activity, activities. + Do not refer to the user; write from the user’s perspective. + + \(LLMOutputLanguagePreferences.languageInstruction(forJSON: true) ?? "") + + GOOD EXAMPLES: + Card 1: Customer interviews wrap-up + Card 2: Insights deck synthesis + Merged Title: Shaped customer story for insights deck + Merged Summary: Logged interview quotes into Airtable. Highlighted the strongest themes and molded them into the insights deck outline. + + Card 1: QA-ing mobile release + Card 2: Answering support tickets + Merged Title: Balanced mobile QA while clearing support + Merged Summary: Ran through the iOS smoke checklist in TestFlight. Hopped into Help Scout to close the urgent tickets. + + BAD EXAMPLES: + ✗ Title: Coding, gaming, and Swift fixes with AI tools and Dayflow (comma list trying to cover everything) + ✗ Title: Busy afternoon session (too vague) + ✗ Summary: Worked on several things across platforms (generic, missing specifics) + ✗ Summary that omits a named site/app/topic from the inputs + ✗ Summary longer than three sentences or formatted as bullet points + + Return JSON: + { + "title": "Merged title", + "summary": "Merged summary" + } + """ + + let maxAttempts = 3 + var prompt = basePrompt + var lastError: Error? + + struct MergedContent: Codable { + let title: String + let summary: String + } + + for attempt in 1...maxAttempts { + do { + let response = try await callTextAPI( + prompt, operation: "merge_cards", expectJSON: true, batchId: batchId) + + guard let data = response.data(using: .utf8) else { + throw NSError( + domain: "MiniMaxProvider", code: 14, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse merged card"]) + } + + let merged = try parseJSONResponse(MergedContent.self, from: data) + + let mergedCard = ActivityCardData( + startTime: previousCard.startTime, + endTime: newCard.endTime, + category: previousCard.category, + subcategory: previousCard.subcategory, + title: merged.title, + summary: merged.summary, + detailedSummary: previousCard.detailedSummary, + distractions: previousCard.distractions, + appSites: previousCard.appSites ?? newCard.appSites + ) + + return (mergedCard, response) + } catch { + lastError = error + if attempt == maxAttempts { throw error } + print("[MINIMAX] ⚠️ merge_cards attempt \(attempt) failed: \(error.localizedDescription)") + prompt = + basePrompt + """ + + + PREVIOUS ATTEMPT FAILED — The response was invalid (error: \(error.localizedDescription)). + Respond with ONLY the JSON object described above containing merged "title" and "summary" fields. + """ + } + } + + throw lastError + ?? NSError( + domain: "MiniMaxProvider", code: 14, + userInfo: [NSLocalizedDescriptionKey: "Failed to merge cards after multiple attempts"]) + } +} diff --git a/Dayflow/Dayflow/Core/AI/MiniMaxProvider+Transcription.swift b/Dayflow/Dayflow/Core/AI/MiniMaxProvider+Transcription.swift new file mode 100644 index 000000000..e50ef8d88 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/MiniMaxProvider+Transcription.swift @@ -0,0 +1,507 @@ +// +// MiniMaxProvider+Transcription.swift +// Dayflow +// +// Screenshot / frame transcription for the MiniMax provider. +// Logic mirrors OllamaProvider+Transcription.swift, adapted for MiniMax M3. +// + +import AppKit +import Foundation + +extension MiniMaxProvider { + fileprivate struct FrameData { + let image: Data // Base64 encoded image + let timestamp: TimeInterval // Seconds from batch start + } + + fileprivate struct VideoSegment: Codable { + let startTimestamp: String // MM:SS format + let endTimestamp: String // MM:SS format + let description: String + } + + fileprivate struct SegmentGroupingResponse: Codable { + let reasoning: String + let segments: [VideoSegment] + } + + fileprivate struct SegmentCoverageError: LocalizedError { + let coverageRatio: Double + let durationString: String + + var percentage: Int { max(0, min(100, Int(coverageRatio * 100))) } + + var errorDescription: String? { + "Segments only cover \(percentage)% of video (expected >80%). Video is \(durationString) long." + } + } + + fileprivate func parseVideoTimestamp(_ timestamp: String) -> Int { + let components = timestamp.components(separatedBy: ":") + if components.count == 3 { + guard let h = Int(components[0]), let m = Int(components[1]), let s = Int(components[2]) else { + return 0 + } + return h * 3600 + m * 60 + s + } else if components.count == 2 { + guard let m = Int(components[0]), let s = Int(components[1]) else { return 0 } + return m * 60 + s + } + return 0 + } + + fileprivate func getSimpleFrameDescription(_ frame: FrameData, batchId: Int64?) async -> String? { + let prompt = """ + Describe what you see on this computer screen in 1-2 sentences. + Focus on: what application/site is open, what the user is doing, and any relevant details visible. + Be specific and factual. + + GOOD EXAMPLES: + ✓ "VS Code open with index.js file, writing a React component for user authentication." + ✓ "Gmail compose window writing email to client@company.com about project timeline." + ✓ "Slack conversation in #engineering channel discussing API rate limiting issues." + + BAD EXAMPLES: + ✗ "User is coding" (too vague) + ✗ "Looking at a website" (doesn't identify which site) + ✗ "Working on computer" (completely non-specific) + """ + + guard let base64String = String(data: frame.image, encoding: .utf8) else { + print("[MINIMAX] ⚠️ Failed to decode frame image — skipping frame") + return nil + } + + let content: [MessageContent] = [ + MessageContent(type: "text", text: prompt, image_url: nil), + MessageContent( + type: "image_url", text: nil, + image_url: MessageContent.ImageURL(url: "data:image/jpeg;base64,\(base64String)")), + ] + + let request = ChatRequest( + model: savedModelId, + messages: [ChatMessage(role: "user", content: content)] + ) + + do { + let response = try await callChatAPI( + request, operation: "describe_frame", batchId: batchId, maxRetries: 1) + let raw = response.choices.first?.message.content ?? "" + return MiniMaxReasoning.stripThinkTags(in: raw) + } catch { + print( + "[MINIMAX] ⚠️ describe_frame failed at \(frame.timestamp)s — skipping frame: \(error.localizedDescription)" + ) + return nil + } + } + + fileprivate func decodeSegmentResponse(_ response: String) throws -> ( + segments: [VideoSegment], reasoning: String + ) { + guard let rawData = response.data(using: .utf8) else { + throw NSError( + domain: "MiniMaxProvider", code: 8, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse segment response"]) + } + + if let object = try? parseJSONResponse(SegmentGroupingResponse.self, from: rawData) { + return (object.segments, object.reasoning) + } + if let array = try? parseJSONResponse([VideoSegment].self, from: rawData) { + return (array, "") + } + if let start = response.firstIndex(of: "{"), + let end = response.lastIndex(of: "}") + { + let substring = response[start...end] + if let data = substring.data(using: .utf8), + let object = try? parseJSONResponse(SegmentGroupingResponse.self, from: data) + { + return (object.segments, object.reasoning) + } + } + if let start = response.firstIndex(of: "["), + let end = response.lastIndex(of: "]") + { + let substring = response[start...end] + if let data = substring.data(using: .utf8), + let array = try? parseJSONResponse([VideoSegment].self, from: data) + { + return (array, "") + } + } + throw NSError( + domain: "MiniMaxProvider", code: 9, + userInfo: [NSLocalizedDescriptionKey: "Could not parse segment response as JSON"]) + } + + fileprivate func convertSegmentsToObservations( + _ segments: [VideoSegment], + batchStartTime: Date, + videoDuration: TimeInterval, + durationString: String + ) throws -> (observations: [Observation], coverage: Double) { + var observations: [Observation] = [] + var totalDuration: TimeInterval = 0 + var lastEndTime: TimeInterval? + + for (index, segment) in segments.enumerated() { + let startSeconds = TimeInterval(parseVideoTimestamp(segment.startTimestamp)) + let endSeconds = TimeInterval(parseVideoTimestamp(segment.endTimestamp)) + + let tolerance: TimeInterval = 30.0 + if startSeconds < -tolerance || endSeconds > videoDuration + tolerance { + print( + "[MINIMAX] ❌ Segment \(index + 1) exceeds video duration: \(segment.startTimestamp)-\(segment.endTimestamp) (video is \(durationString))" + ) + continue + } + + let clampedDuration = max(0, endSeconds - startSeconds) + totalDuration += clampedDuration + lastEndTime = endSeconds + + let startDate = batchStartTime.addingTimeInterval(startSeconds) + let endDate = batchStartTime.addingTimeInterval(endSeconds) + + observations.append( + Observation( + id: nil, + batchId: 0, + startTs: Int(startDate.timeIntervalSince1970), + endTs: Int(endDate.timeIntervalSince1970), + observation: segment.description, + metadata: nil, + llmModel: savedModelId, + createdAt: Date() + ) + ) + } + + if observations.isEmpty { + throw NSError( + domain: "MiniMaxProvider", code: 11, + userInfo: [ + NSLocalizedDescriptionKey: + "Screenshots failed to process - check MiniMax API configuration or report a bug." + ]) + } + + if observations.count > 5 { + throw NSError( + domain: "MiniMaxProvider", code: 13, + userInfo: [ + NSLocalizedDescriptionKey: + "Generated \(observations.count) observations, but expected 2-5. The LLM must follow the instruction to create EXACTLY 2-5 segments." + ]) + } + + let coverage = videoDuration > 0 ? totalDuration / videoDuration : 0 + return (observations, coverage) + } + + fileprivate func observationsFromFrames( + _ frameDescriptions: [(timestamp: TimeInterval, description: String)], + batchStartTime: Date, + videoDuration: TimeInterval + ) throws -> [Observation] { + guard !frameDescriptions.isEmpty else { + throw NSError( + domain: "MiniMaxProvider", + code: 11, + userInfo: [ + NSLocalizedDescriptionKey: + "It looks like the MiniMax API is not responding. Please check your API key and network connection in settings." + ] + ) + } + + let sortedFrames = frameDescriptions.sorted { $0.timestamp < $1.timestamp } + let durationCap = videoDuration > 0 ? videoDuration : nil + var observations: [Observation] = [] + + for (index, frame) in sortedFrames.enumerated() { + let startSeconds = max(0, frame.timestamp) + var endSeconds = startSeconds + screenshotInterval + + if index + 1 < sortedFrames.count { + endSeconds = min(endSeconds, sortedFrames[index + 1].timestamp) + } + if let cap = durationCap { + endSeconds = min(endSeconds, cap) + } + if endSeconds <= startSeconds { + endSeconds = startSeconds + max(1, screenshotInterval) + if let cap = durationCap { endSeconds = min(endSeconds, cap) } + } + + let startDate = batchStartTime.addingTimeInterval(startSeconds) + let endDate = batchStartTime.addingTimeInterval(endSeconds) + + observations.append( + Observation( + id: nil, + batchId: 0, + startTs: Int(startDate.timeIntervalSince1970), + endTs: Int(endDate.timeIntervalSince1970), + observation: frame.description, + metadata: nil, + llmModel: nil, + createdAt: Date() + ) + ) + } + return observations + } + + fileprivate func mergeFrameDescriptions( + _ frameDescriptions: [(timestamp: TimeInterval, description: String)], + batchStartTime: Date, + videoDuration: TimeInterval, + batchId: Int64? + ) async throws -> [Observation] { + var formattedDescriptions = "" + for frame in frameDescriptions { + let minutes = Int(frame.timestamp) / 60 + let seconds = Int(frame.timestamp) % 60 + let timeStr = String(format: "%02d:%02d", minutes, seconds) + formattedDescriptions += "[\(timeStr)] \(frame.description)\n" + } + + let durationMinutes = Int(videoDuration / 60) + let durationSeconds = Int(videoDuration.truncatingRemainder(dividingBy: 60)) + let durationString = String(format: "%02d:%02d", durationMinutes, durationSeconds) + + let basePrompt = """ + You have \(frameDescriptions.count) snapshots from a \(durationString) screen recording. + + CRITICAL TASK: Group these snapshots into EXACTLY 2-5 coherent segments that collectively explain \(durationString) of activity. Brief interruptions (< 2 minutes) should be absorbed into the surrounding segment. + + Here are the snapshots (timestamp → description): + \(formattedDescriptions) + + Respond with a JSON object using this exact shape: + { + "reasoning": "Use this space to think through how you're going to construct the segments", + "segments": [ + { + "startTimestamp": "MM:SS", + "endTimestamp": "MM:SS", + "description": "Natural language summary of what happened" + } + ] + } + + HARD REQUIREMENTS: + - "segments" MUST contain between 2 and 5 items. + - Every timestamp must stay within 00:00 and \(durationString). + - Segments should cover at least 80% of the video (ideally 100%) without inventing events. + - Merge small gaps instead of creating tiny standalone segments. + - Never output additional text outside the JSON object. + """ + + let maxAttempts = 2 + var prompt = basePrompt + var lastError: Error? + + for attempt in 1...maxAttempts { + do { + let response = try await callTextAPI( + prompt, operation: "segment_video_activity", expectJSON: true, batchId: batchId) + let (segments, _) = try decodeSegmentResponse(response) + let (observations, coverage) = try convertSegmentsToObservations( + segments, + batchStartTime: batchStartTime, + videoDuration: videoDuration, + durationString: durationString + ) + + if coverage < 0.8 { + throw SegmentCoverageError(coverageRatio: coverage, durationString: durationString) + } + return observations + } catch let coverageError as SegmentCoverageError { + lastError = coverageError + if attempt == maxAttempts { + print("[MINIMAX] ❌ segment_video_activity retries exhausted (coverage)") + return try observationsFromFrames( + frameDescriptions, + batchStartTime: batchStartTime, + videoDuration: videoDuration + ) + } + prompt = + basePrompt + """ + + + PREVIOUS ATTEMPT FAILED — Your segments only covered \(coverageError.percentage)% of the \(durationString) video. + Merge adjacent snapshots or extend segment boundaries so the segments cover at least 80% of the runtime without inventing events. + """ + } catch { + lastError = error + if attempt == maxAttempts { + print("[MINIMAX] ❌ segment_video_activity retries exhausted (error: \(error.localizedDescription))") + return try observationsFromFrames( + frameDescriptions, + batchStartTime: batchStartTime, + videoDuration: videoDuration + ) + } + prompt = + basePrompt + """ + + + PREVIOUS ATTEMPT FAILED — The response was invalid (error: \(error.localizedDescription)). + Respond with ONLY the JSON object described above. Ensure it contains a "reasoning" string and a "segments" array with 2-5 items covering at least 80% of the video. + """ + } + } + + throw lastError + ?? NSError( + domain: "MiniMaxProvider", code: 9, + userInfo: [NSLocalizedDescriptionKey: "Failed to generate merged observations after multiple attempts"]) + } +} + +// MARK: - Public transcription entry point + +extension MiniMaxProvider { + /// Transcribe observations from screenshots. + func transcribeScreenshots( + _ screenshots: [Screenshot], + batchStartTime: Date, + batchId: Int64? + ) async throws -> (observations: [Observation], log: LLMCall) { + guard !screenshots.isEmpty else { + throw NSError( + domain: "MiniMaxProvider", code: 12, + userInfo: [NSLocalizedDescriptionKey: "No screenshots to transcribe"]) + } + + let callStart = Date() + let sortedScreenshots = screenshots.sorted { $0.capturedAt < $1.capturedAt } + + // Sample ~15 evenly spaced screenshots to keep the request bounded. + let targetSamples = 15 + let strideAmount = max(1, sortedScreenshots.count / targetSamples) + let sampledScreenshots = Swift.stride(from: 0, to: sortedScreenshots.count, by: strideAmount) + .map { sortedScreenshots[$0] } + + let firstTs = sampledScreenshots.first!.capturedAt + let lastTs = sampledScreenshots.last!.capturedAt + let durationSeconds = TimeInterval(lastTs - firstTs) + + var frameDescriptions: [(timestamp: TimeInterval, description: String)] = [] + for screenshot in sampledScreenshots { + guard let frameData = loadScreenshotAsFrameData(screenshot, relativeTo: firstTs) else { + print("[MINIMAX] ⚠️ Failed to load screenshot: \(screenshot.filePath)") + continue + } + if let description = await getSimpleFrameDescription(frameData, batchId: batchId) { + frameDescriptions.append((timestamp: frameData.timestamp, description: description)) + } + } + + guard !frameDescriptions.isEmpty else { + throw NSError( + domain: "MiniMaxProvider", + code: 11, + userInfo: [ + NSLocalizedDescriptionKey: + "Failed to describe any screenshots. Please verify your MiniMax API key and that the model is available." + ] + ) + } + + let observations = try await mergeFrameDescriptions( + frameDescriptions, + batchStartTime: batchStartTime, + videoDuration: durationSeconds, + batchId: batchId + ) + + let totalTime = Date().timeIntervalSince(callStart) + let log = LLMCall( + timestamp: callStart, + latency: totalTime, + input: "Screenshot transcription: \(screenshots.count) screenshots → \(observations.count) observations", + output: "Processed \(screenshots.count) screenshots in \(String(format: "%.2f", totalTime))s" + ) + + return (observations, log) + } + + fileprivate func loadScreenshotAsFrameData( + _ screenshot: Screenshot, relativeTo baseTimestamp: Int + ) -> FrameData? { + guard let imageData = loadScreenshotDataForMiniMax(screenshot) else { + return nil + } + let base64String = imageData.base64EncodedString() + let base64Data = Data(base64String.utf8) + let relativeTimestamp = TimeInterval(screenshot.capturedAt - baseTimestamp) + return FrameData(image: base64Data, timestamp: relativeTimestamp) + } + + private func loadScreenshotDataForMiniMax( + _ screenshot: Screenshot, maxHeight: Double = 720, jpegQuality: CGFloat = 0.85 + ) -> Data? { + let url = URL(fileURLWithPath: screenshot.filePath) + guard let image = NSImage(contentsOf: url) else { + return try? Data(contentsOf: url) + } + let rep = + image.representations.compactMap { $0 as? NSBitmapImageRep }.first + ?? image.representations.first + let pixelsWide = rep?.pixelsWide ?? Int(image.size.width) + let pixelsHigh = rep?.pixelsHigh ?? Int(image.size.height) + + if pixelsHigh <= Int(maxHeight) { + return try? Data(contentsOf: url) + } + + let scale = maxHeight / Double(pixelsHigh) + let targetW = max(2, Int((Double(pixelsWide) * scale).rounded(.toNearestOrAwayFromZero))) + let targetH = Int(maxHeight) + + guard + let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: targetW, + pixelsHigh: targetH, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .calibratedRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) + else { return nil } + + bitmap.size = NSSize(width: targetW, height: targetH) + NSGraphicsContext.saveGraphicsState() + guard let ctx = NSGraphicsContext(bitmapImageRep: bitmap) else { + NSGraphicsContext.restoreGraphicsState() + return nil + } + NSGraphicsContext.current = ctx + image.draw( + in: NSRect(x: 0, y: 0, width: CGFloat(targetW), height: CGFloat(targetH)), + from: NSRect(origin: .zero, size: image.size), + operation: .copy, + fraction: 1.0, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high] + ) + ctx.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + + let props: [NSBitmapImageRep.PropertyKey: Any] = [.compressionFactor: jpegQuality] + return bitmap.representation(using: NSBitmapImageRep.FileType.jpeg, properties: props) + } +} diff --git a/Dayflow/Dayflow/Core/AI/MiniMaxProvider.swift b/Dayflow/Dayflow/Core/AI/MiniMaxProvider.swift new file mode 100644 index 000000000..3b696a738 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/MiniMaxProvider.swift @@ -0,0 +1,253 @@ +// +// MiniMaxProvider.swift +// Dayflow +// +// LLM provider for MiniMax M3 — a multimodal model from MiniMax. +// Uses the OpenAI-compatible Chat Completions endpoint at https://api.minimax.io/v1. +// API key is stored in the macOS Keychain (key: "minimax"). +// + +import AppKit +import Foundation + +final class MiniMaxProvider { + // MARK: - Constants + + /// The default API base URL. Can be overridden in init for tests. + static let defaultEndpoint = "https://api.minimax.io/v1" + + /// Keychain key for the API key. + static let keychainKey = "minimax" + + /// The default model identifier for MiniMax M3. + static let defaultModelId = "MiniMax-M3" + + // MARK: - Stored properties + + let endpoint: String + + /// The current model ID. Read from UserDefaults (key: "llmMiniMaxModelId") if present, + /// otherwise falls back to `defaultModelId`. + var savedModelId: String { + if let m = UserDefaults.standard.string(forKey: "llmMiniMaxModelId"), !m.isEmpty { + return m + } + return Self.defaultModelId + } + + /// API key loaded from Keychain. May be nil if the user has not yet configured one. + var apiKey: String? { + guard let raw = KeychainManager.shared.retrieve(for: Self.keychainKey) else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Screenshot interval in seconds. Mirrors `OllamaProvider.screenshotInterval` so + /// frame-stitching logic in extensions stays consistent. + let screenshotInterval: TimeInterval = 10 + + /// For analytics tracking. + var providerName: String { "minimax" } + + // MARK: - Init + + init(endpoint: String = MiniMaxProvider.defaultEndpoint) { + self.endpoint = endpoint + } + + // MARK: - Helpers (mirrored from OllamaProvider for consistency) + + /// Strip user references from observations to prevent the LLM from using third-person + /// language. Mirrors the behaviour of `OllamaProvider.stripUserReferences`. + func stripUserReferences(_ text: String) -> String { + return text + .replacingOccurrences(of: "The user", with: "", options: .caseInsensitive) + .replacingOccurrences(of: "A user", with: "", options: .caseInsensitive) + } + + func logCallDuration(operation: String, duration: TimeInterval, status: Int? = nil) { + let statusText = status.map { " status=\($0)" } ?? "" + print("⏱️ [MiniMax] \(operation) \(String(format: "%.2f", duration))s\(statusText)") + } + + // MARK: - Activity card generation (public entry point) + + func generateActivityCards( + observations: [Observation], + context: ActivityGenerationContext, + batchId: Int64? + ) async throws -> (cards: [ActivityCardData], log: LLMCall) { + let callStart = Date() + var logs: [String] = [] + + let sortedObservations = context.batchObservations.sorted { $0.startTs < $1.startTs } + + guard let firstObservation = sortedObservations.first, + let lastObservation = sortedObservations.last + else { + throw NSError( + domain: "MiniMaxProvider", + code: 16, + userInfo: [ + NSLocalizedDescriptionKey: "Cannot generate activity cards: no observations provided" + ] + ) + } + + // 1. Generate initial title+summary for these observations. + let (titleSummary, firstLog) = try await generateTitleAndSummary( + observations: sortedObservations, + categories: context.categories, + batchId: batchId + ) + logs.append(firstLog) + + let normalizedCategory = normalizeCategory( + titleSummary.category, categories: context.categories) + + let initialCard = ActivityCardData( + startTime: formatTimestampForPrompt(firstObservation.startTs), + endTime: formatTimestampForPrompt(lastObservation.endTs), + category: normalizedCategory, + subcategory: "", + title: titleSummary.title, + summary: titleSummary.summary, + detailedSummary: "", + distractions: nil, + appSites: titleSummary.appSites + ) + + var allCards = context.existingCards + + // 2. Decide whether to merge with the last existing card. + if !allCards.isEmpty, let lastExistingCard = allCards.last { + let lastCardDuration = calculateDurationInMinutes( + from: lastExistingCard.startTime, to: lastExistingCard.endTime) + + if lastCardDuration >= 40 { + allCards.append(initialCard) + } else { + let gapMinutes = calculateDurationInMinutes( + from: lastExistingCard.endTime, to: initialCard.startTime) + if gapMinutes > 5 { + allCards.append(initialCard) + } else { + let candidateDuration = calculateDurationInMinutes( + from: lastExistingCard.startTime, to: initialCard.endTime) + if candidateDuration > 60 { + allCards.append(initialCard) + } else { + let (shouldMerge, mergeLog) = try await checkShouldMerge( + previousCard: lastExistingCard, + newCard: initialCard, + batchId: batchId + ) + logs.append(mergeLog) + + if shouldMerge { + let (mergedCard, mergeCreateLog) = try await mergeTwoCards( + previousCard: lastExistingCard, + newCard: initialCard, + batchId: batchId + ) + + let mergedDuration = calculateDurationInMinutes( + from: mergedCard.startTime, to: mergedCard.endTime) + + if mergedDuration > 60 { + allCards.append(initialCard) + } else { + logs.append(mergeCreateLog) + allCards[allCards.count - 1] = mergedCard + } + } else { + allCards.append(initialCard) + } + } + } + } + } else { + allCards.append(initialCard) + } + + let totalLatency = Date().timeIntervalSince(callStart) + + let combinedLog = LLMCall( + timestamp: callStart, + latency: totalLatency, + input: "Two-pass activity card generation", + output: logs.joined(separator: "\n\n---\n\n") + ) + + return (allCards, combinedLog) + } + + // MARK: - Utility helpers (also mirrored from OllamaProvider) + + private func normalizeCategory(_ raw: String, categories: [LLMCategoryDescriptor]) -> String { + let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return categories.first?.name ?? "" } + let normalized = cleaned.lowercased() + if let match = categories.first(where: { + $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized + }) { + return match.name + } + if let idle = categories.first(where: { $0.isIdle }) { + let idleLabels = [ + "idle", "idle time", idle.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + ] + if idleLabels.contains(normalized) { + return idle.name + } + } + return categories.first?.name ?? cleaned + } + + func parseJSONResponse(_ type: T.Type, from data: Data) throws -> T { + do { + return try JSONDecoder().decode(type, from: data) + } catch { + guard let responseString = String(data: data, encoding: .utf8) else { + throw error + } + if let startIndex = responseString.firstIndex(of: "{"), + let endIndex = responseString.lastIndex(of: "}") + { + let jsonSubstring = responseString[startIndex...endIndex] + if let jsonData = jsonSubstring.data(using: .utf8) { + return try JSONDecoder().decode(type, from: jsonData) + } + } + throw error + } + } + + func formatTimestampForPrompt(_ timestamp: Int) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + return formatter.string(from: date) + } + + private func calculateDurationInMinutes(from startTime: String, to endTime: String) -> Int { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + + guard let start = formatter.date(from: startTime), + let end = formatter.date(from: endTime) + else { + return 0 + } + + var duration = end.timeIntervalSince(start) + if duration < 0 { + duration += 24 * 60 * 60 + } + return Int(duration / 60) + } +} diff --git a/Dayflow/Dayflow/Core/AI/ModelCatalog.swift b/Dayflow/Dayflow/Core/AI/ModelCatalog.swift new file mode 100644 index 000000000..a17753263 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/ModelCatalog.swift @@ -0,0 +1,517 @@ +// +// ModelCatalog.swift +// Dayflow +// +// Centralized model catalog for all LLM providers. Lets the user pick from +// models fetched live from the provider's API where possible, falling back +// to a curated static list where the provider doesn't expose a list endpoint. +// +// Each provider has a `fetchModels()` implementation. Results are cached in +// UserDefaults with a TTL so we don't hammer the API on every app launch. +// + +import AppKit +import Foundation + +// MARK: - Model option + +struct LLMModelOption: Codable, Identifiable, Hashable { + let id: String + let displayName: String + let family: String + let contextWindow: Int? + let visionCapable: Bool + let isDeprecated: Bool + let isRecommended: Bool + let notes: String? +} + +extension LLMModelOption { + static func curated( + _ id: String, + displayName: String? = nil, + family: String = "", + contextWindow: Int? = nil, + visionCapable: Bool = false, + isDeprecated: Bool = false, + isRecommended: Bool = false, + notes: String? = nil + ) -> LLMModelOption { + LLMModelOption( + id: id, + displayName: displayName ?? id, + family: family, + contextWindow: contextWindow, + visionCapable: visionCapable, + isDeprecated: isDeprecated, + isRecommended: isRecommended, + notes: notes + ) + } +} + +// MARK: - Cached list + +struct ModelListSnapshot: Codable { + let providerID: String + let models: [LLMModelOption] + let fetchedAt: Date + let source: Source + let fetchError: String? + + enum Source: String, Codable { + case live // came from the provider API + case curated // from the in-app fallback list + } + + var isStale: Bool { + Date().timeIntervalSince(fetchedAt) > 24 * 60 * 60 + } +} + +// MARK: - Catalog + +actor ModelCatalog { + static let shared = ModelCatalog() + + private let cacheKey = "llmModelCatalogCache.v1" + private let ttl: TimeInterval = 24 * 60 * 60 // 24h + + private var cache: [String: ModelListSnapshot] = [:] + private var inFlight: [String: Task] = [:] + + init() { + if let data = UserDefaults.standard.data(forKey: cacheKey), + let decoded = try? JSONDecoder().decode([String: ModelListSnapshot].self, from: data) + { + cache = decoded + } + } + + // MARK: Public read + + func cachedList(for providerID: String) -> ModelListSnapshot? { + cache[providerID] + } + + /// Returns the cached list immediately (possibly stale) and then triggers a + /// background refresh. The UI binds to the returned snapshot and re-renders + /// when the refresh finishes. + func snapshot(for providerID: String) -> ModelListSnapshot? { + cache[providerID] + } + + // MARK: Public refresh + + /// Force a fresh fetch from the provider. Falls back to the curated list + /// when the network call fails. + func refresh(for providerID: String) async throws -> ModelListSnapshot { + if let inflight = inFlight[providerID] { + return try await inflight.value + } + let task = Task { [weak self] in + guard let self else { throw CancellationError() } + return try await self.performFetch(for: providerID) + } + inFlight[providerID] = task + defer { inFlight[providerID] = nil } + let snapshot = try await task.value + cache[providerID] = snapshot + persistCache() + return snapshot + } + + /// Used when the user picks a model that isn't in the current snapshot — + /// e.g. a custom model ID or one that was just removed from the API. + func ensureModelExists(_ modelID: String, in providerID: String) { + guard var snapshot = cache[providerID] else { return } + if snapshot.models.contains(where: { $0.id == modelID }) { return } + let custom = LLMModelOption.curated( + modelID, displayName: "\(modelID) (custom)", isRecommended: false) + snapshot = ModelListSnapshot( + providerID: providerID, + models: [custom] + snapshot.models, + fetchedAt: snapshot.fetchedAt, + source: snapshot.source, + fetchError: snapshot.fetchError + ) + cache[providerID] = snapshot + persistCache() + } + + // MARK: Internals + + private func performFetch(for providerID: String) async throws -> ModelListSnapshot { + do { + let models: [LLMModelOption] + switch providerID { + case "gemini": + models = try await fetchGeminiModels() + case "minimax": + models = try await fetchMiniMaxModels() + case "ollama": + models = try await fetchOllamaModels() + case "codex": + models = curatedCodexModels + case "claude": + models = curatedClaudeModels + default: + models = [] + } + return ModelListSnapshot( + providerID: providerID, + models: models, + fetchedAt: Date(), + source: .live, + fetchError: nil + ) + } catch { + let fallback = ModelListSnapshot( + providerID: providerID, + models: curatedFallback(for: providerID), + fetchedAt: Date(), + source: .curated, + fetchError: error.localizedDescription + ) + return fallback + } + } + + private func persistCache() { + if let data = try? JSONEncoder().encode(cache) { + UserDefaults.standard.set(data, forKey: cacheKey) + } + } + + // MARK: Per-provider live fetchers + + private func fetchGeminiModels() async throws -> [LLMModelOption] { + guard let apiKey = KeychainManager.shared.retrieve(for: "gemini"), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw NSError( + domain: "ModelCatalog", code: 401, + userInfo: [NSLocalizedDescriptionKey: "Gemini API key is not configured"]) + } + var components = URLComponents(string: "https://generativelanguage.googleapis.com/v1beta/models") + components?.queryItems = [URLQueryItem(name: "key", value: apiKey)] + guard let url = components?.url else { + throw NSError( + domain: "ModelCatalog", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid Gemini models URL"]) + } + + struct Resp: Codable { + let models: [Model] + struct Model: Codable { + let name: String + let displayName: String? + let inputTokenLimit: Int? + let outputTokenLimit: Int? + let supportedGenerationMethods: [String]? + + enum CodingKeys: String, CodingKey { + case name + case displayName = "displayName" + case inputTokenLimit = "inputTokenLimit" + case outputTokenLimit = "outputTokenLimit" + case supportedGenerationMethods = "supportedGenerationMethods" + } + } + } + + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + throw NSError( + domain: "ModelCatalog", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Gemini models endpoint error: \(body)"]) + } + let decoded = try JSONDecoder().decode(Resp.self, from: data) + return decoded.models.compactMap { m in + let family = m.name.split(separator: "-").first.map(String.init) ?? "" + let vision = m.supportedGenerationMethods?.contains("generateContent") == true + let deprecated = m.name.contains("1.0") || m.name.contains("1.5") + return LLMModelOption.curated( + m.name, + displayName: m.displayName ?? m.name, + family: family, + contextWindow: m.inputTokenLimit, + visionCapable: vision, + isDeprecated: deprecated, + isRecommended: m.name.contains("2.5") || m.name.contains("2.0-flash"), + notes: nil + ) + } + } + + private func fetchMiniMaxModels() async throws -> [LLMModelOption] { + guard let url = URL(string: "https://api.minimax.io/v1/models") else { + throw NSError( + domain: "ModelCatalog", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid MiniMax models URL"]) + } + var req = URLRequest(url: url) + req.httpMethod = "GET" + req.timeoutInterval = 30 + if let key = KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey), + !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + } + struct Resp: Codable { + let data: [Model] + struct Model: Codable { + let id: String + } + } + let (data, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + throw NSError( + domain: "ModelCatalog", code: 1, + userInfo: [NSLocalizedDescriptionKey: "MiniMax models endpoint error: \(body)"]) + } + let decoded = try JSONDecoder().decode(Resp.self, from: data) + return decoded.data.map { m in + let family: String + if m.id.lowercased().contains("m3") { family = "M3" } + else if m.id.lowercased().contains("m2") { family = "M2" } + else { family = "M-series" } + return LLMModelOption.curated( + m.id, family: family, visionCapable: true, + isRecommended: m.id == "MiniMax-M3", + notes: m.id == "MiniMax-M3" ? "Default. 1M-token context, multimodal." : nil + ) + } + } + + private func fetchOllamaModels() async throws -> [LLMModelOption] { + let baseURL = UserDefaults.standard.string(forKey: "llmLocalBaseURL") ?? "http://localhost:11434" + let engine = UserDefaults.standard.string(forKey: "llmLocalEngine") ?? "ollama" + let trimmedBase = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let urlString: String + if engine == "lmstudio" { + urlString = "\(trimmedBase)/v1/models" + } else { + urlString = "\(trimmedBase)/api/tags" + } + guard let url = URL(string: urlString) else { + throw NSError( + domain: "ModelCatalog", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid local models URL"]) + } + var req = URLRequest(url: url) + req.httpMethod = "GET" + req.timeoutInterval = 15 + if engine == "lmstudio" { + req.setValue("Bearer lm-studio", forHTTPHeaderField: "Authorization") + } + + struct OllamaResp: Codable { + let models: [OllamaModel] + struct OllamaModel: Codable { + let name: String + let size: Int64? + let details: Details? + struct Details: Codable { + let family: String? + let parameter_size: String? + } + } + } + struct LMSResp: Codable { + let data: [LMModel] + struct LMModel: Codable { + let id: String + } + } + let (data, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + throw NSError( + domain: "ModelCatalog", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Local models endpoint error: \(body)"]) + } + if engine == "lmstudio" { + let decoded = try JSONDecoder().decode(LMSResp.self, from: data) + return decoded.data.map { m in + LLMModelOption.curated( + m.id, family: "Local", + visionCapable: isLikelyVisionModel(m.id), + isRecommended: m.id.contains("qwen3-vl") || m.id.contains("vision")) + } + } else { + let decoded = try JSONDecoder().decode(OllamaResp.self, from: data) + return decoded.models.map { m in + LLMModelOption.curated( + m.name, family: m.details?.family ?? "Local", + visionCapable: isLikelyVisionModel(m.name), + isRecommended: m.name.contains("qwen3-vl") || m.name.contains("vision") + ) + } + } + } + + private func isLikelyVisionModel(_ name: String) -> Bool { + let lower = name.lowercased() + return lower.contains("vl") || lower.contains("vision") || lower.contains("llava") + || lower.contains("qwen2.5vl") || lower.contains("qwen3-vl") + } + + // MARK: Curated fallbacks (used when live fetch fails or for providers + // that have no public list endpoint) + + private func curatedFallback(for providerID: String) -> [LLMModelOption] { + switch providerID { + case "gemini": return curatedGeminiModels + case "minimax": return curatedMiniMaxModels + case "ollama": return curatedOllamaModels + case "codex": return curatedCodexModels + case "claude": return curatedClaudeModels + default: return [] + } + } + + private var curatedGeminiModels: [LLMModelOption] { + [ + .curated( + "gemini-3.5-flash", displayName: "Gemini 3.5 Flash", family: "Gemini 3.5", + contextWindow: 1_048_576, visionCapable: true, isRecommended: true, + notes: "Default workhorse. Fast, 1M context, multimodal." + ), + .curated( + "gemini-2.5-pro", displayName: "Gemini 2.5 Pro", family: "Gemini 2.5", + contextWindow: 1_048_576, visionCapable: true, isRecommended: false, + notes: "Best for complex reasoning. Scheduled for shutdown 2026-10-16." + ), + .curated( + "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", family: "Gemini 2.5", + contextWindow: 1_048_576, visionCapable: true, isRecommended: false, + notes: "Faster, lower cost. Scheduled for shutdown 2026-10-16." + ), + .curated( + "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash-Lite", + family: "Gemini 2.5", contextWindow: 1_048_576, visionCapable: true, + isRecommended: false, notes: "Cheapest, fastest." + ), + .curated( + "gemini-2.0-flash", displayName: "Gemini 2.0 Flash", family: "Gemini 2.0", + contextWindow: 1_048_576, visionCapable: true, isDeprecated: true, + notes: "Shut down 2026-06-01." + ), + .curated( + "gemini-1.5-pro", displayName: "Gemini 1.5 Pro", family: "Gemini 1.5", + contextWindow: 2_097_152, visionCapable: true, isDeprecated: true, + notes: "Deprecated." + ), + ] + } + + private var curatedMiniMaxModels: [LLMModelOption] { + [ + .curated( + "MiniMax-M3", displayName: "MiniMax M3", family: "M3", + contextWindow: 1_000_000, visionCapable: true, isRecommended: true, + notes: "Default. Frontier multimodal, 1M context." + ), + .curated( + "MiniMax-M2.7", displayName: "MiniMax M2.7", family: "M2", + contextWindow: 1_000_000, visionCapable: true, isRecommended: false, + notes: "Previous flagship." + ), + .curated( + "MiniMax-M2.7-highspeed", displayName: "MiniMax M2.7 Highspeed", + family: "M2", contextWindow: 1_000_000, visionCapable: true, + isRecommended: false, notes: "Lower-latency M2.7." + ), + .curated( + "MiniMax-M2.5", displayName: "MiniMax M2.5", family: "M2", + contextWindow: 1_000_000, visionCapable: true, isRecommended: false + ), + .curated( + "MiniMax-M2.5-highspeed", displayName: "MiniMax M2.5 Highspeed", + family: "M2", contextWindow: 1_000_000, visionCapable: true, + isRecommended: false + ), + ] + } + + private var curatedOllamaModels: [LLMModelOption] { + [ + .curated("qwen3-vl:4b", displayName: "Qwen3-VL 4B (Recommended)", + family: "Qwen3-VL", contextWindow: 32_768, visionCapable: true, + isRecommended: true, + notes: "Dayflow's recommended local VLM."), + .curated("qwen2.5vl:3b", displayName: "Qwen2.5-VL 3B", + family: "Qwen2.5-VL", contextWindow: 32_768, visionCapable: true, + isRecommended: false), + .curated("llama3.2-vision:11b", displayName: "Llama 3.2 Vision 11B", + family: "Llama 3.2", contextWindow: 128_000, visionCapable: true, + isRecommended: false), + .curated("llava:13b", displayName: "LLaVA 13B", + family: "LLaVA", contextWindow: 4_096, visionCapable: true, + isRecommended: false), + .curated("gemma3:4b", displayName: "Gemma 3 4B", + family: "Gemma 3", contextWindow: 128_000, visionCapable: true, + isRecommended: false), + ] + } + + private var curatedCodexModels: [LLMModelOption] { + [ + .curated("gpt-4o", displayName: "GPT-4o", family: "GPT-4o", + contextWindow: 128_000, visionCapable: true, isRecommended: true), + .curated("gpt-4o-mini", displayName: "GPT-4o mini", family: "GPT-4o", + contextWindow: 128_000, visionCapable: true, isRecommended: false), + .curated("gpt-4.1", displayName: "GPT-4.1", family: "GPT-4.1", + contextWindow: 1_000_000, visionCapable: true, isRecommended: false), + .curated("gpt-4.1-mini", displayName: "GPT-4.1 mini", family: "GPT-4.1", + contextWindow: 1_000_000, visionCapable: true, isRecommended: false), + .curated("o3", displayName: "o3", family: "o-series", + contextWindow: 200_000, visionCapable: false, isRecommended: false), + .curated("o3-mini", displayName: "o3-mini", family: "o-series", + contextWindow: 200_000, visionCapable: false, isRecommended: false), + .curated("o4-mini", displayName: "o4-mini", family: "o-series", + contextWindow: 200_000, visionCapable: false, isRecommended: false), + ] + } + + private var curatedClaudeModels: [LLMModelOption] { + [ + .curated( + "claude-opus-4-8", displayName: "Claude Opus 4.8", family: "Opus", + contextWindow: 1_000_000, visionCapable: true, isRecommended: true, + notes: "Frontier. Best for complex agent tasks." + ), + .curated( + "claude-opus-4-7", displayName: "Claude Opus 4.7", family: "Opus", + contextWindow: 1_000_000, visionCapable: true, isRecommended: false + ), + .curated( + "claude-sonnet-4-5", displayName: "Claude Sonnet 4.5", family: "Sonnet", + contextWindow: 1_000_000, visionCapable: true, isRecommended: false, + notes: "Dayflow's default. Good balance." + ), + .curated( + "claude-3-5-sonnet-latest", displayName: "Claude 3.5 Sonnet (latest)", + family: "Sonnet", contextWindow: 200_000, visionCapable: true, + isDeprecated: true + ), + .curated( + "claude-3-5-haiku-latest", displayName: "Claude 3.5 Haiku (latest)", + family: "Haiku", contextWindow: 200_000, visionCapable: true, + isRecommended: false, notes: "Cheapest, fastest Claude." + ), + ] + } +} + +// MARK: - Provider id helpers + +extension LLMProviderID { + /// The provider-id used by `ModelCatalog`. (Today this is the same string + /// the settings UI uses, but keeping the indirection lets us rename later + /// without rewriting every catalog call site.) + var modelCatalogID: String { rawValue } +} diff --git a/Dayflow/Dayflow/Core/Analytics/ProviderStatsCalculator.swift b/Dayflow/Dayflow/Core/Analytics/ProviderStatsCalculator.swift new file mode 100644 index 000000000..3bfee0202 --- /dev/null +++ b/Dayflow/Dayflow/Core/Analytics/ProviderStatsCalculator.swift @@ -0,0 +1,209 @@ +// +// ProviderStatsCalculator.swift +// Dayflow +// +// Derives per-provider / per-model / per-day / per-week usage statistics +// from a list of `TimelineCard`s. Used by the dashboard view to render +// the GitHub-commit-style heatmap and the provider breakdown. +// + +import Foundation + +// MARK: - Public models + +struct ProviderStatsSummary { + /// Total number of cards in the input. Cached because every view re-reads it. + let totalCards: Int + + /// Cards per provider ID ("gemini", "minimax", "ollama", "chatgpt_claude", "dayflow"). + /// Always includes an entry for the 5 known providers, even if zero. + let byProvider: [String: Int] + + /// Cards per (providerID|modelID) tuple — the per-model breakdown. + let byModel: [(providerID: String, modelID: String, count: Int)] + + /// Daily buckets covering the most recent `days` days (default 365). + let daily: [DailyStat] + /// Weekly buckets covering the most recent `weeks` weeks (default 26). + let weekly: [WeeklyStat] + /// Monthly buckets covering the most recent `months` months (default 12). + let monthly: [MonthlyStat] + + /// Earliest card date in the dataset (or nil if no cards). + let earliest: Date? + /// Latest card date in the dataset (or nil if no cards). + let latest: Date? + + /// A flat (date, count) list for the GitHub-style heatmap (defaults to the + /// last 365 days). The view is responsible for rendering the grid. + let heatmapDays: [HeatmapDay] + + /// The most active day in `daily`, useful as a quick callout. + let peakDay: DailyStat? +} + +struct DailyStat: Identifiable, Hashable { + var id: Date { date } + let date: Date + let count: Int +} + +struct WeeklyStat: Identifiable, Hashable { + var id: Date { weekStart } + let weekStart: Date // Monday of the week + let count: Int +} + +struct MonthlyStat: Identifiable, Hashable { + var id: Date { monthStart } + let monthStart: Date // First day of the month + let count: Int +} + +struct HeatmapDay: Identifiable, Hashable { + var id: Date { date } + let date: Date + let count: Int + /// Normalized intensity 0...1, where 1.0 = peak day in the dataset. + let intensity: Double +} + +// MARK: - Calculator + +enum ProviderStatsCalculator { + static let knownProviders: [String] = [ + "gemini", "minimax", "ollama", "chatgpt_claude", "dayflow" + ] + + /// Compute a summary from a list of cards. The cards may span any time + /// range; the calculator normalizes to calendar boundaries (Mon-Sun weeks, + /// 1st-of-month months, …). + static func summary( + from cards: [TimelineCard], + days: Int = 365, + weeks: Int = 26, + months: Int = 12, + calendar: Calendar = .current + ) -> ProviderStatsSummary { + let total = cards.count + + // byProvider + var byProvider: [String: Int] = [:] + for p in knownProviders { byProvider[p] = 0 } + for card in cards { + let key = (card.providerId ?? "unknown").trimmingCharacters(in: .whitespacesAndNewlines) + let bucket = key.isEmpty ? "unknown" : key + byProvider[bucket, default: 0] += 1 + } + + // byModel + var byModelDict: [String: (providerID: String, modelID: String, count: Int)] = [:] + for card in cards { + let p = (card.providerId ?? "unknown").trimmingCharacters(in: .whitespacesAndNewlines) + let m = (card.modelId ?? "unknown").trimmingCharacters(in: .whitespacesAndNewlines) + let pKey = p.isEmpty ? "unknown" : p + let mKey = m.isEmpty ? "unknown" : m + let key = "\(pKey)|\(mKey)" + if var existing = byModelDict[key] { + existing.count += 1 + byModelDict[key] = existing + } else { + byModelDict[key] = (providerID: pKey, modelID: mKey, count: 1) + } + } + let byModel = byModelDict.values + .sorted { lhs, rhs in + if lhs.count != rhs.count { return lhs.count > rhs.count } + if lhs.providerID != rhs.providerID { return lhs.providerID < rhs.providerID } + return lhs.modelID < rhs.modelID + } + + // Date parsing helpers + func parseDate(_ s: String) -> Date? { + let formatters: [DateFormatter] = { + let iso = DateFormatter() + iso.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + iso.locale = Locale(identifier: "en_US_POSIX") + let alt = DateFormatter() + alt.dateFormat = "yyyy-MM-dd HH:mm:ss" + alt.locale = Locale(identifier: "en_US_POSIX") + return [iso, alt] + }() + for f in formatters { if let d = f.date(from: s) { return d } } + return nil + } + + let dates: [Date] = cards.compactMap { parseDate($0.startTimestamp) } + let earliest = dates.min() + let latest = dates.max() + + // Daily + let today = calendar.startOfDay(for: Date()) + var dailyCounts: [Date: Int] = [:] + for d in dates { + let day = calendar.startOfDay(for: d) + dailyCounts[day, default: 0] += 1 + } + let daily: [DailyStat] = (0.. [TimelineCard] { + let decoder = JSONDecoder() + let cards = try? timedRead("fetchAllTimelineCards") { db in + try Row.fetchAll( + db, + sql: """ + SELECT * FROM timeline_cards + WHERE is_deleted = 0 + ORDER BY start_ts ASC + """ + ) + .map { row in + var distractions: [Distraction]? = nil + var appSites: AppSites? = nil + var isBackupGenerated: Bool? = nil + if let metadataString: String = row["metadata"], + let jsonData = metadataString.data(using: .utf8) + { + if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) { + distractions = meta.distractions + appSites = meta.appSites + isBackupGenerated = meta.isBackupGenerated + } else if let legacy = try? decoder.decode([Distraction].self, from: jsonData) { + distractions = legacy + } + } + return TimelineCard( + recordId: row["id"], + batchId: row["batch_id"], + startTimestamp: row["start"] ?? "", + endTimestamp: row["end"] ?? "", + category: row["category"], + subcategory: row["subcategory"], + title: row["title"], + summary: row["summary"], + detailedSummary: row["detailed_summary"], + day: row["day"], + distractions: distractions, + videoSummaryURL: row["video_summary_url"], + otherVideoSummaryURLs: nil, + appSites: appSites, + isBackupGenerated: isBackupGenerated, + providerId: row["provider_id"], + modelId: row["model_id"] + ) + } + } + return cards ?? [] + } + func fetchTimelineCards(forDay day: String) -> [TimelineCard] { let decoder = JSONDecoder() @@ -478,7 +542,9 @@ extension StorageManager { videoSummaryURL: row["video_summary_url"], otherVideoSummaryURLs: nil, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: row["provider_id"], + modelId: row["model_id"] ) } } @@ -539,7 +605,9 @@ extension StorageManager { videoSummaryURL: row["video_summary_url"], otherVideoSummaryURLs: nil, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: row["provider_id"], + modelId: row["model_id"] ) } } @@ -941,13 +1009,15 @@ extension StorageManager { sql: """ INSERT INTO timeline_cards( batch_id, start, end, start_ts, end_ts, day, title, - summary, category, subcategory, detailed_summary, metadata + summary, category, subcategory, detailed_summary, metadata, + provider_id, model_id ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, arguments: [ batchId, card.startTimestamp, card.endTimestamp, startTs, endTs, dayString, card.title, card.summary, card.category, card.subcategory, card.detailedSummary, metadataString, + card.providerId, card.modelId, ]) // Capture the ID of the inserted card diff --git a/Dayflow/Dayflow/Core/Recording/StorageManager.swift b/Dayflow/Dayflow/Core/Recording/StorageManager.swift index 702f6f92f..3d84e9cd2 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManager.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManager.swift @@ -700,6 +700,33 @@ final class StorageManager: StorageManaging, @unchecked Sendable { print("✅ Added is_deleted column and composite indexes to timeline_cards") } + // Migration: add provider_id and model_id columns to timeline_cards + let timelineCardsColumnsForProvider = try db.columns(in: "timeline_cards").map { $0.name } + if !timelineCardsColumnsForProvider.contains("provider_id") { + try db.execute( + sql: """ + ALTER TABLE timeline_cards ADD COLUMN provider_id TEXT; + """) + print("✅ Added provider_id column to timeline_cards") + } + if !timelineCardsColumnsForProvider.contains("model_id") { + try db.execute( + sql: """ + ALTER TABLE timeline_cards ADD COLUMN model_id TEXT; + """) + print("✅ Added model_id column to timeline_cards") + } + if timelineCardsColumnsForProvider.contains("provider_id") + || timelineCardsColumnsForProvider.contains("model_id") + { + try db.execute( + sql: """ + CREATE INDEX IF NOT EXISTS idx_timeline_cards_provider_model + ON timeline_cards(provider_id, model_id) + WHERE is_deleted = 0; + """) + } + let screenshotColumns = try db.columns(in: "screenshots").map { $0.name } if !screenshotColumns.contains("idle_seconds_at_capture") { try db.execute( diff --git a/Dayflow/Dayflow/Core/Recording/StorageManaging.swift b/Dayflow/Dayflow/Core/Recording/StorageManaging.swift index 38ca0d37d..3bc4a83ea 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManaging.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManaging.swift @@ -35,6 +35,7 @@ protocol StorageManaging: Sendable { // Timeline Queries func fetchTimelineCards(forDay day: String) -> [TimelineCard] func fetchTimelineCardsByTimeRange(from: Date, to: Date) -> [TimelineCard] + func fetchAllTimelineCards() -> [TimelineCard] func fetchTotalMinutesTracked(from: Date, to: Date) -> Double func fetchTotalMinutesTrackedForWeek(containing date: Date) -> Double func replaceTimelineCardsInRange(from: Date, to: Date, with: [TimelineCardShell], batchId: Int64) diff --git a/Dayflow/Dayflow/Core/Recording/StorageModels.swift b/Dayflow/Dayflow/Core/Recording/StorageModels.swift index 6a8b6deac..4852bf093 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageModels.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageModels.swift @@ -115,6 +115,8 @@ struct TimelineCard: Codable, Sendable, Identifiable { let otherVideoSummaryURLs: [String]? // For merged cards, subsequent video URLs let appSites: AppSites? let isBackupGenerated: Bool? + let providerId: String? + let modelId: String? init( id: UUID = UUID(), @@ -132,7 +134,9 @@ struct TimelineCard: Codable, Sendable, Identifiable { videoSummaryURL: String?, otherVideoSummaryURLs: [String]?, appSites: AppSites?, - isBackupGenerated: Bool? = nil + isBackupGenerated: Bool? = nil, + providerId: String? = nil, + modelId: String? = nil ) { self.id = id self.recordId = recordId @@ -150,6 +154,8 @@ struct TimelineCard: Codable, Sendable, Identifiable { self.otherVideoSummaryURLs = otherVideoSummaryURLs self.appSites = appSites self.isBackupGenerated = isBackupGenerated + self.providerId = providerId + self.modelId = modelId } } @@ -234,6 +240,8 @@ struct TimelineCardShell: Sendable { let appSites: AppSites? let isBackupGenerated: Bool? let idleMetadata: IdleCardMetadata? + let providerId: String? + let modelId: String? // No videoSummaryURL here, as it's added later // No batchId here, as it's passed as a separate parameter to the save function @@ -248,7 +256,9 @@ struct TimelineCardShell: Sendable { distractions: [Distraction]?, appSites: AppSites?, isBackupGenerated: Bool? = nil, - idleMetadata: IdleCardMetadata? = nil + idleMetadata: IdleCardMetadata? = nil, + providerId: String? = nil, + modelId: String? = nil ) { self.startTimestamp = startTimestamp self.endTimestamp = endTimestamp @@ -261,6 +271,8 @@ struct TimelineCardShell: Sendable { self.appSites = appSites self.isBackupGenerated = isBackupGenerated self.idleMetadata = idleMetadata + self.providerId = providerId + self.modelId = modelId } } diff --git a/Dayflow/Dayflow/Utilities/MiniMaxAPIHelper.swift b/Dayflow/Dayflow/Utilities/MiniMaxAPIHelper.swift new file mode 100644 index 000000000..0c1116928 --- /dev/null +++ b/Dayflow/Dayflow/Utilities/MiniMaxAPIHelper.swift @@ -0,0 +1,94 @@ +// +// MiniMaxAPIHelper.swift +// Dayflow +// +// Small utilities specific to the MiniMax provider. +// + +import Foundation + +enum MiniMaxAPIHelper { + /// Returns true if the user has supplied a non-empty MiniMax API key in the Keychain. + static var hasAPIKey: Bool { + guard let key = KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey) else { + return false + } + return !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Persist (or remove) the API key in the Keychain. + /// Pass `nil` or an empty string to remove the key. + static func setAPIKey(_ key: String?) { + let trimmed = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + KeychainManager.shared.delete(for: MiniMaxProvider.keychainKey) + } else { + KeychainManager.shared.store(trimmed, for: MiniMaxProvider.keychainKey) + } + } + + /// Quick connectivity smoke-test that sends a minimal chat-completion request. + /// Throws on non-200 responses or transport errors. + static func smokeTest(model: String = MiniMaxProvider.defaultModelId) async throws -> String { + guard let key = KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey), + !key.isEmpty + else { + throw NSError( + domain: "MiniMaxAPIHelper", code: 1, + userInfo: [NSLocalizedDescriptionKey: "MiniMax API key is not configured"]) + } + + guard let url = LocalEndpointUtilities.chatCompletionsURL( + baseURL: MiniMaxProvider.defaultEndpoint) + else { + throw NSError( + domain: "MiniMaxAPIHelper", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Invalid MiniMax endpoint URL"]) + } + + struct Req: Codable { + let model: String + let messages: [Msg] + let max_tokens: Int + let temperature: Double + } + struct Msg: Codable { + let role: String + let content: String + } + struct Resp: Codable { + let choices: [Choice] + } + struct Choice: Codable { + let message: ChoiceMessage + } + struct ChoiceMessage: Codable { + let content: String + } + + let body = Req( + model: model, + messages: [Msg(role: "user", content: "ping")], + max_tokens: 16, + temperature: 0 + ) + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + req.httpBody = try JSONEncoder().encode(body) + req.timeoutInterval = 30 + + let (data, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + throw NSError( + domain: "MiniMaxAPIHelper", code: (response as? HTTPURLResponse)?.statusCode ?? 3, + userInfo: [NSLocalizedDescriptionKey: "MiniMax API error: \(body)"]) + } + let decoded = try JSONDecoder().decode(Resp.self, from: data) + let raw = decoded.choices.first?.message.content ?? "" + return MiniMaxReasoning.stripThinkTags(in: raw) + } +} diff --git a/Dayflow/Dayflow/Views/Onboarding/LLMProviderSetupView.swift b/Dayflow/Dayflow/Views/Onboarding/LLMProviderSetupView.swift index 8950c2210..f7b23b551 100644 --- a/Dayflow/Dayflow/Views/Onboarding/LLMProviderSetupView.swift +++ b/Dayflow/Dayflow/Views/Onboarding/LLMProviderSetupView.swift @@ -17,6 +17,8 @@ struct LLMProviderSetupView: View { return "Connect Claude" case .openAICompatible: return "Connect an AI endpoint" + case .minimax: + return "Connect MiniMax M3" case .gemini: return "Gemini" case .dayflow: diff --git a/Dayflow/Dayflow/Views/Onboarding/Prototype/OnboardingPrototypeChooseProviderStep.swift b/Dayflow/Dayflow/Views/Onboarding/Prototype/OnboardingPrototypeChooseProviderStep.swift index 4603c04a5..ec3df501c 100644 --- a/Dayflow/Dayflow/Views/Onboarding/Prototype/OnboardingPrototypeChooseProviderStep.swift +++ b/Dayflow/Dayflow/Views/Onboarding/Prototype/OnboardingPrototypeChooseProviderStep.swift @@ -102,6 +102,14 @@ struct OnboardingPrototypeChooseProviderStep: View { ease: RatedValue(text: "API key and model", rating: .medium), notes: "Uses OpenRouter or any OpenAI-compatible endpoint." ), + ComparisonProvider( + providerID: .minimax, + title: "MiniMax M3", + accuracy: RatedValue(text: "Best", rating: .best), + subscription: "MiniMax Token Plan", + ease: RatedValue(text: "API key", rating: .medium), + notes: "Frontier multimodal model • 1M-token context window • native image & video understanding." + ), ComparisonProvider( providerID: .local, title: "Local AI", @@ -291,6 +299,8 @@ struct OnboardingPrototypeChooseProviderStep: View { iconCircle(imageName: "GeminiLogo") case .openAICompatible: iconCircle(systemName: "network") + case .minimax: + iconCircle(systemName: "sparkles") case .local: iconCircle(systemName: "laptopcomputer") } diff --git a/Dayflow/Dayflow/Views/Onboarding/ProviderSetupState.swift b/Dayflow/Dayflow/Views/Onboarding/ProviderSetupState.swift index 6e7d6bb60..b10bae93a 100644 --- a/Dayflow/Dayflow/Views/Onboarding/ProviderSetupState.swift +++ b/Dayflow/Dayflow/Views/Onboarding/ProviderSetupState.swift @@ -21,6 +21,7 @@ class ProviderSetupState: ObservableObject { @Published var openAICompatibleBaseURL: String = OpenAICompatibleConfiguration.openRouterBaseURL @Published var openAICompatibleModelID: String = "" @Published var openAICompatibleAPIKey: String = "" + @Published var minimaxAPIKey: String = "" // CLI detection @Published var codexCLIStatus: CLIDetectionState = .unknown @Published var claudeCLIStatus: CLIDetectionState = .unknown @@ -67,6 +68,8 @@ class ProviderSetupState: ObservableObject { } openAICompatibleAPIKey = KeychainManager.shared.retrieve(for: OpenAICompatiblePreferences.keychainProvider) ?? "" + minimaxAPIKey = + KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey) ?? "" } var currentStep: SetupStep { @@ -214,6 +217,29 @@ class ProviderSetupState: ObservableObject { contentType: .information( "All set!", "Gemini is now configured and ready to use with Dayflow.")), ] + case .minimax: + let storedMiniMaxKey = + KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + hasStoredGeminiAPIKey = !storedMiniMaxKey.isEmpty + steps = [ + SetupStep( + id: "getkey", title: "Get API key", + contentType: .apiKeyInstructions), + SetupStep( + id: "enterkey", title: "Enter API key", + contentType: .apiKeyInput), + SetupStep( + id: "verify", title: "Test connection", + contentType: .information( + "Test Connection", + "Click the button below to verify your API key works with MiniMax M3.")), + SetupStep( + id: "complete", title: "Complete", + contentType: .information( + "All set!", + "MiniMax M3 is now configured and ready to use with Dayflow.")), + ] } } diff --git a/Dayflow/Dayflow/Views/UI/Dashboard/ProviderStatsView.swift b/Dayflow/Dayflow/Views/UI/Dashboard/ProviderStatsView.swift new file mode 100644 index 000000000..25e85a536 --- /dev/null +++ b/Dayflow/Dayflow/Views/UI/Dashboard/ProviderStatsView.swift @@ -0,0 +1,505 @@ +// +// ProviderStatsView.swift +// Dayflow +// +// Dashboard surface that visualises which Dayflow provider + model produced +// which activity cards, in the same GitHub-contribution-graph style the user +// asked for. Read-only — all state is loaded on appear from +// `StorageManager` and re-computed when the underlying `TimelineCard` list +// changes. +// + +import SwiftUI + +@MainActor +final class ProviderStatsViewModel: ObservableObject { + @Published private(set) var summary: ProviderStatsSummary = .empty + @Published private(set) var isLoading: Bool = false + + func reload() { + isLoading = true + Task.detached(priority: .userInitiated) { + let cards = await StorageManager.shared.fetchAllTimelineCards() + let s = ProviderStatsCalculator.summary(from: cards) + await MainActor.run { + self.summary = s + self.isLoading = false + } + } + } +} + +extension ProviderStatsSummary { + static let empty = ProviderStatsSummary( + totalCards: 0, + byProvider: [:], + byModel: [], + daily: [], + weekly: [], + monthly: [], + earliest: nil, + latest: nil, + heatmapDays: [], + peakDay: nil + ) +} + +struct ProviderStatsView: View { + @StateObject private var viewModel = ProviderStatsViewModel() + @State private var selectedRange: TimeRange = .year + + enum TimeRange: String, CaseIterable, Identifiable { + case week = "Week" + case month = "Month" + case quarter = "Quarter" + case year = "Year" + var id: String { rawValue } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + summaryStats + providerBreakdown + modelBreakdown + heatmapSection + timeSeriesSection + } + .padding(20) + } + .background(Color(NSColor.windowBackgroundColor)) + .onAppear { viewModel.reload() } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Usage statistics") + .font(.custom("Figtree", size: 22)) + .fontWeight(.semibold) + Spacer() + if viewModel.isLoading { + ProgressView().scaleEffect(0.6) + } else { + Button { + viewModel.reload() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Recompute statistics from local storage") + } + } + Text( + viewModel.summary.totalCards == 0 + ? "No activity cards yet. Once Dayflow processes a recording, you'll see your provider and model usage here." + : "Tracks which Dayflow provider and model produced each activity card. All data stays on your Mac." + ) + .font(.custom("Figtree", size: 12)) + .foregroundColor(.secondary) + } + } + + // MARK: - Summary cards + + private var summaryStats: some View { + let s = viewModel.summary + return HStack(spacing: 12) { + statTile( + title: "Total cards", + value: "\(s.totalCards)", + icon: "rectangle.stack" + ) + statTile( + title: "Active providers", + value: "\(s.byProvider.values.filter { $0 > 0 }.count)", + icon: "cpu" + ) + statTile( + title: "Peak day", + value: s.peakDay.map { "\($0.count) on " + dateLabel($0.date) } ?? "—", + icon: "flame" + ) + statTile( + title: "Tracking from", + value: s.earliest.map { dateLabel($0) } ?? "—", + icon: "calendar" + ) + } + } + + private func statTile(title: String, value: String, icon: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Label(title, systemImage: icon) + .font(.custom("Figtree", size: 11)) + .foregroundColor(.secondary) + Text(value) + .font(.custom("Figtree", size: 18).weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.controlBackgroundColor)) + ) + } + + // MARK: - Provider breakdown + + private var providerBreakdown: some View { + let s = viewModel.summary + let total = max(1, s.totalCards) + let entries = ProviderStatsCalculator.knownProviders.compactMap { id -> (String, Int)? in + let count = s.byProvider[id] ?? 0 + return count > 0 ? (displayName(for: id), count) : nil + } + let maxValue = max(1, entries.map(\.1).max() ?? 1) + return sectionCard(title: "Provider breakdown", subtitle: "Cards produced by each provider") { + if entries.isEmpty { + Text("No provider activity yet.") + .font(.custom("Figtree", size: 12)) + .foregroundColor(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(0.. some View { + let maxValue = max(1, entries.map(\.1).max() ?? 1) + if entries.isEmpty { + Text("No data for this range yet.") + .font(.custom("Figtree", size: 12)) + .foregroundColor(.secondary) + } else { + HStack(alignment: .bottom, spacing: 2) { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + VStack(spacing: 4) { + Text("\(entry.1)") + .font(.custom("Figtree", size: 8)) + .foregroundColor(.secondary) + Rectangle() + .fill(color.opacity(0.6 + 0.4 * Double(entry.1) / Double(maxValue))) + .frame(height: max(2, CGFloat(entry.1) / CGFloat(maxValue) * 60)) + Text(dateLabel(entry.0, short: true)) + .font(.custom("Figtree", size: 8)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + } + } + .frame(height: 110) + } + } + + // MARK: - Helpers + + private func sectionCard( + title: String, subtitle: String, @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.custom("Figtree", size: 14).weight(.semibold)) + Text(subtitle) + .font(.custom("Figtree", size: 11)) + .foregroundColor(.secondary) + } + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(NSColor.controlBackgroundColor)) + ) + } + + @ViewBuilder + private func providerBar(entry: (String, Int), total: Int, maxValue: Int) -> some View { + HStack(spacing: 8) { + Text(entry.0) + .frame(width: 110, alignment: .leading) + .font(.custom("Figtree", size: 12)) + GeometryReader { proxy in + let width = proxy.size.width * CGFloat(entry.1) / CGFloat(maxValue) + RoundedRectangle(cornerRadius: 3) + .fill(colorFor(providerID: entry.0)) + .frame(width: max(4, width), height: 14) + } + .frame(height: 14) + Text("\(entry.1)") + .font(.custom("Figtree", size: 12).monospacedDigit()) + .foregroundColor(.secondary) + .frame(width: 40, alignment: .trailing) + Text("\(Int(round(Double(entry.1) / Double(total) * 100)))%") + .font(.custom("Figtree", size: 10)) + .foregroundColor(SettingsStyle.meta) + .frame(width: 40, alignment: .trailing) + } + } + + private func displayName(for providerID: String) -> String { + switch providerID { + case "gemini": return "Gemini" + case "minimax": return "MiniMax" + case "ollama": return "Local AI" + case "chatgpt_claude": return "ChatGPT / Claude" + case "dayflow": return "Dayflow Pro" + default: return providerID + } + } + + private func colorFor(providerID: String) -> Color { + switch providerID { + case "gemini": return .blue + case "minimax": return .purple + case "ollama": return .green + case "chatgpt_claude": return .orange + case "dayflow": return .pink + default: return .gray + } + } + + private func dateLabel(_ d: Date, short: Bool = false) -> String { + let f = DateFormatter() + f.dateFormat = short ? "MMM d" : "MMM d, yyyy" + return f.string(from: d) + } +} + +// MARK: - Heatmap view + +struct HeatmapView: View { + let days: [HeatmapDay] + + private static let cellSize: CGFloat = 11 + private static let cellSpacing: CGFloat = 2 + + /// Group `days` into calendar weeks, anchored to the most recent Sunday. + private var grid: [[HeatmapDay?]] { + guard !days.isEmpty else { return [] } + let calendar = Calendar.current + let firstDay = days.first?.date ?? Date() + let lastDay = days.last?.date ?? Date() + let weekdayOfLast = calendar.component(.weekday, from: lastDay) + let trailingPad = (7 - weekdayOfLast) % 7 + + // Map date -> HeatmapDay for quick lookup + var byDate: [Date: HeatmapDay] = [:] + for d in days { byDate[calendar.startOfDay(for: d.date)] = d } + + var weeks: [[HeatmapDay?]] = [] + var current = Array(repeating: nil, count: 7) + var cursor = calendar.startOfDay(for: firstDay) + let endDay = calendar.date(byAdding: .day, value: trailingPad, to: lastDay) ?? lastDay + while cursor <= endDay { + let weekday = calendar.component(.weekday, from: cursor) // 1...7 (Sun...Sat) + current[weekday - 1] = byDate[cursor] + if weekday == 7 { + weeks.append(current) + current = Array(repeating: nil, count: 7) + } + cursor = calendar.date(byAdding: .day, value: 1, to: cursor) ?? cursor.addingTimeInterval(86400) + } + if current.contains(where: { $0 != nil }) { + weeks.append(current) + } + return weeks + } + + private let monthFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM" + return f + }() + + var body: some View { + if days.isEmpty { + Text("No activity in the last year yet.") + .font(.custom("Figtree", size: 12)) + .foregroundColor(.secondary) + } else { + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 2) { + Color.clear.frame(width: 22) + ForEach(Array(grid.enumerated()), id: \.offset) { weekIndex, week in + let firstDate = week.compactMap { $0?.date }.first + if let firstDate, weekIndex == 0 || calendar.component(.weekday, from: firstDate) == 1 + { + Text(monthFormatter.string(from: firstDate)) + .font(.custom("Figtree", size: 9)) + .foregroundColor(.secondary) + .frame(width: Self.cellSize + Self.cellSpacing, alignment: .leading) + } else { + Color.clear.frame(width: Self.cellSize + Self.cellSpacing, height: 10) + } + } + } + HStack(alignment: .top, spacing: 2) { + VStack(alignment: .leading, spacing: 0) { + Text("M") + Text("W") + Text("F") + } + .font(.custom("Figtree", size: 8)) + .foregroundColor(.secondary) + .frame(width: 22, alignment: .leading) + ForEach(Array(grid.enumerated()), id: \.offset) { _, week in + VStack(spacing: Self.cellSpacing) { + ForEach(Array(week.enumerated()), id: \.offset) { _, day in + Rectangle() + .fill(colorFor(intensity: day?.intensity ?? 0)) + .frame(width: Self.cellSize, height: Self.cellSize) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.black.opacity(0.06), lineWidth: 0.5) + ) + .help(heatmapTooltip(for: day)) + } + } + } + } + HStack(spacing: 6) { + Text("Less") + .font(.custom("Figtree", size: 9)) + .foregroundColor(.secondary) + ForEach(0..<5) { i in + Rectangle() + .fill(colorFor(intensity: Double(i) / 4.0)) + .frame(width: Self.cellSize, height: Self.cellSize) + } + Text("More") + .font(.custom("Figtree", size: 9)) + .foregroundColor(.secondary) + } + .padding(.top, 6) + } + } + } + } + + private let calendar = Calendar.current + + private func colorFor(intensity: Double) -> Color { + switch intensity { + case 0: return Color(NSColor.controlBackgroundColor).opacity(0.5) + case 0..<0.25: return Color.green.opacity(0.25) + case 0.25..<0.5: return Color.green.opacity(0.45) + case 0.5..<0.75: return Color.green.opacity(0.65) + default: return Color.green.opacity(0.85) + } + } + + private func heatmapTooltip(for day: HeatmapDay?) -> String { + guard let day else { return "" } + let f = DateFormatter() + f.dateStyle = .medium + return "\(f.string(from: day.date)): \(day.count) card\(day.count == 1 ? "" : "s")" + } +} diff --git a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel+PromptOverrides.swift b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel+PromptOverrides.swift index fd93067c0..5451ca4d0 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel+PromptOverrides.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel+PromptOverrides.swift @@ -134,7 +134,7 @@ extension ProvidersSettingsViewModel { ClaudePromptDefaults.summaryBlock, ClaudePromptDefaults.detailedSummaryBlock ) - case .dayflow, .gemini, .openAICompatible, .local: + case .dayflow, .gemini, .openAICompatible, .minimax, .local: assertionFailure("Agent prompt editor only supports Codex and Claude") overrides = ActivityCardPromptOverrides() defaults = ( @@ -181,7 +181,7 @@ extension ProvidersSettingsViewModel { CodexPromptPreferences.reset() case .claude: ClaudePromptPreferences.reset() - case .dayflow, .gemini, .openAICompatible, .local: + case .dayflow, .gemini, .openAICompatible, .minimax, .local: assertionFailure("Agent prompt editor only supports Codex and Claude") } } else { @@ -190,7 +190,7 @@ extension ProvidersSettingsViewModel { CodexPromptPreferences.save(overrides) case .claude: ClaudePromptPreferences.save(overrides) - case .dayflow, .gemini, .openAICompatible, .local: + case .dayflow, .gemini, .openAICompatible, .minimax, .local: assertionFailure("Agent prompt editor only supports Codex and Claude") } } @@ -212,7 +212,7 @@ extension ProvidersSettingsViewModel { ClaudePromptDefaults.summaryBlock, ClaudePromptDefaults.detailedSummaryBlock ) - case .dayflow, .gemini, .openAICompatible, .local: + case .dayflow, .gemini, .openAICompatible, .minimax, .local: assertionFailure("Agent prompt editor only supports Codex and Claude") defaults = ( CodexPromptDefaults.titleBlock, @@ -231,7 +231,7 @@ extension ProvidersSettingsViewModel { CodexPromptPreferences.reset() case .claude: ClaudePromptPreferences.reset() - case .dayflow, .gemini, .openAICompatible, .local: + case .dayflow, .gemini, .openAICompatible, .minimax, .local: assertionFailure("Agent prompt editor only supports Codex and Claude") } isUpdatingAgentPromptState = false diff --git a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift index 402febfe9..469122f4d 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift @@ -58,6 +58,19 @@ final class ProvidersSettingsViewModel: ObservableObject { @Published private(set) var claudeCLIInstalled = false @Published private(set) var isCheckingCLIReadiness = false + @Published var selectedMiniMaxModel: String = MiniMaxProvider.defaultModelId { + didSet { + guard oldValue != selectedMiniMaxModel else { return } + UserDefaults.standard.set(selectedMiniMaxModel, forKey: "llmMiniMaxModelId") + } + } + @Published var minimaxAPIKey: String = "" { + didSet { + guard oldValue != minimaxAPIKey else { return } + MiniMaxAPIHelper.setAPIKey(minimaxAPIKey) + } + } + @Published var geminiPromptOverridesLoaded = false @Published var isUpdatingGeminiPromptState = false @Published var useCustomGeminiTitlePrompt = false { @@ -159,6 +172,10 @@ final class ProvidersSettingsViewModel: ObservableObject { } openAICompatibleAPIKey = KeychainManager.shared.retrieve(for: OpenAICompatiblePreferences.keychainProvider) ?? "" + + let minimaxPref = MiniMaxModelPreference.load() + selectedMiniMaxModel = minimaxPref.modelId + minimaxAPIKey = KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey) ?? "" } func handleOnAppear() { @@ -166,6 +183,7 @@ final class ProvidersSettingsViewModel: ObservableObject { loadRouting() reloadLocalProviderSettings() reloadOpenAICompatibleSettings() + reloadMiniMaxProviderSettings() refreshCLIReadiness() LocalModelPreferences.syncPreset(for: localEngine, modelId: localModelId) refreshUpgradeBannerState() @@ -235,6 +253,12 @@ final class ProvidersSettingsViewModel: ObservableObject { KeychainManager.shared.retrieve(for: OpenAICompatiblePreferences.keychainProvider) ?? "" } + func reloadMiniMaxProviderSettings() { + let preference = MiniMaxModelPreference.load() + selectedMiniMaxModel = preference.modelId + minimaxAPIKey = KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey) ?? "" + } + func refreshCLIReadiness() { guard !isCheckingCLIReadiness else { return } isCheckingCLIReadiness = true @@ -381,6 +405,8 @@ final class ProvidersSettingsViewModel: ObservableObject { let preference = GeminiModelPreference.load() selectedGeminiModel = preference.primary savedGeminiModel = preference.primary + case .minimax: + reloadMiniMaxProviderSettings() case .dayflow: break } @@ -590,6 +616,9 @@ final class ProvidersSettingsViewModel: ObservableObject { KeychainManager.shared.retrieve( for: OpenAICompatiblePreferences.keychainProvider) ?? "" return !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .minimax: + let key = KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey) ?? "" + return !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty case .local: let baseURL = (UserDefaults.standard.string(forKey: "llmLocalBaseURL") ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -706,6 +735,10 @@ final class ProvidersSettingsViewModel: ObservableObject { properties["endpoint_kind"] = openAICompatiblePreset.rawValue properties["has_api_key"] = !openAICompatibleAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } else if providerId == .minimax { + properties["model_id"] = selectedMiniMaxModel + properties["has_api_key"] = + !minimaxAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } AnalyticsService.shared.capture("provider_setup_completed", properties) } @@ -743,6 +776,10 @@ final class ProvidersSettingsViewModel: ObservableObject { id: .gemini, summary: "Gemini free tier • fast & accurate" ), + CompactProviderInfo( + id: .minimax, + summary: "Frontier multimodal model • 1M-token context • MiniMax M3" + ), CompactProviderInfo( id: .openAICompatible, summary: "OpenRouter or another OpenAI Chat Completions endpoint" @@ -778,6 +815,12 @@ final class ProvidersSettingsViewModel: ObservableObject { case .openAICompatible: return openAICompatibleModelID.isEmpty ? "OpenAI-compatible endpoint" : openAICompatibleModelID + case .minimax: + let model = UserDefaults.standard.string(forKey: "llmMiniMaxModelId")? + .trimmingCharacters(in: .whitespacesAndNewlines) + let displayModel = + (model?.isEmpty == false ? model! : MiniMaxProvider.defaultModelId) + return "MiniMax M3 – \(displayModel)" case .dayflow: return isDayflowProActive ? "Dayflow Pro active" : "Requires Dayflow Pro" } @@ -809,6 +852,8 @@ final class ProvidersSettingsViewModel: ObservableObject { return "Claude Code" case .openAICompatible: return "OpenAI-compatible API" + case .minimax: + return "MiniMax API" case .dayflow: return "Dayflow Backend" } @@ -821,6 +866,7 @@ final class ProvidersSettingsViewModel: ObservableObject { case .chatGPT: return "ChatGPT" case .claude: return "Claude" case .openAICompatible: return "OpenAI-compatible" + case .minimax: return "MiniMax M3" case .dayflow: return "Dayflow Pro" } } @@ -840,6 +886,7 @@ struct CompactProviderInfo: Identifiable { case .chatGPT: return "ChatGPT" case .claude: return "Claude" case .openAICompatible: return "OpenAI-compatible" + case .minimax: return "MiniMax M3" case .dayflow: return "Dayflow Pro" } } diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift index de9e3a5e9..447663d43 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift @@ -142,6 +142,21 @@ struct SettingsProvidersTabView: View { SettingsRow(label: "API key", showsDivider: false) { SettingsMetadata(text: hasKey ? "Stored safely in Keychain" : "Not set") } + case .minimax: + SettingsRow(label: "Model") { + SettingsMetadata( + text: viewModel.selectedMiniMaxModel.isEmpty + ? MiniMaxProvider.defaultModelId + : viewModel.selectedMiniMaxModel) + } + SettingsRow(label: "Endpoint") { + SettingsMetadata(text: MiniMaxProvider.defaultEndpoint) + } + SettingsRow(label: "API key", showsDivider: false) { + SettingsMetadata( + text: KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey) != nil + ? "Stored safely in Keychain" : "Not set") + } case .dayflow: SettingsRow(label: "Status", showsDivider: false) { SettingsMetadata(text: viewModel.statusText(for: .dayflow) ?? "Requires Dayflow Pro") @@ -198,6 +213,18 @@ struct SettingsProvidersTabView: View { viewModel.editProviderConfiguration(.openAICompatible) } } + case .minimax: + VStack(alignment: .leading, spacing: 10) { + Text( + "Dayflow sends a small test image to MiniMax M3 to verify the connection. A small token charge may apply." + ) + .font(.custom("Figtree", size: 12)) + .foregroundColor(SettingsStyle.secondary) + .fixedSize(horizontal: false, vertical: true) + SettingsSecondaryButton(title: "Test connection") { + viewModel.editProviderConfiguration(.minimax) + } + } case .dayflow: Text("Hosted cards and transcription run through your Dayflow account.") .font(.custom("Figtree", size: 13)) @@ -427,7 +454,7 @@ struct SettingsProvidersTabView: View { ], onReset: viewModel.resetOllamaPromptOverrides ) - case .dayflow, .chatGPT, .claude, .openAICompatible: + case .dayflow, .chatGPT, .claude, .openAICompatible, .minimax: EmptyView() } } From dab53e0012ba5a214fbbc50b045b516be73925b3 Mon Sep 17 00:00:00 2001 From: M3NT1 Date: Sun, 19 Jul 2026 21:00:10 +0200 Subject: [PATCH 2/3] feat: per-card provider badge + ChatCLI model pass-through --- .../AI/ClaudeProvider+ActivityCards.swift | 2 +- .../Dayflow/Core/AI/ClaudeProvider+Text.swift | 9 +++- .../AI/ClaudeProvider+Transcription.swift | 2 +- Dayflow/Dayflow/Core/AI/ClaudeProvider.swift | 38 ++++++++++++++- .../Core/AI/CodexProvider+ActivityCards.swift | 2 +- .../Dayflow/Core/AI/CodexProvider+Text.swift | 9 +++- .../Core/AI/CodexProvider+Transcription.swift | 2 +- Dayflow/Dayflow/Core/AI/CodexProvider.swift | 40 +++++++++++++++- Dayflow/Dayflow/Core/AI/LLMService.swift | 47 ++++++++++++++----- .../Dayflow/Views/UI/CanvasActivityCard.swift | 39 ++++++++++----- .../Views/UI/CanvasTimelineDataView.swift | 34 ++++++++++++++ .../UI/MainView/TimelineActivityLoader.swift | 4 +- .../UI/MainView/WeekTimelineGridPreview.swift | 4 +- .../Dayflow/Views/UI/TimelineDataModels.swift | 19 ++++++-- .../Views/UI/TimelineReviewTypes.swift | 4 +- .../TimelineActivityLoaderTests.swift | 4 +- 16 files changed, 218 insertions(+), 41 deletions(-) diff --git a/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift b/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift index c5dc0abe7..e4b06ff8b 100644 --- a/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift +++ b/Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift @@ -28,7 +28,7 @@ extension ClaudeProvider { ) async throws -> (cards: [ActivityCardData], log: LLMCall) { let callStart = Date() let prompt = buildCardsPrompt(observations: observations, context: context) - let modelConfiguration = Self.activityCardModelConfiguration() + let modelConfiguration = resolvedActivityCardModelConfiguration() let model = modelConfiguration.model let effort = modelConfiguration.reasoningEffort var run: ChatCLIRunResult? diff --git a/Dayflow/Dayflow/Core/AI/ClaudeProvider+Text.swift b/Dayflow/Dayflow/Core/AI/ClaudeProvider+Text.swift index 44a67710b..3bd78bd6d 100644 --- a/Dayflow/Dayflow/Core/AI/ClaudeProvider+Text.swift +++ b/Dayflow/Dayflow/Core/AI/ClaudeProvider+Text.swift @@ -8,7 +8,7 @@ extension ClaudeProvider { tool: .claude, prompt: prompt, workingDirectory: config.workingDirectory, - model: "sonnet", + model: resolvedChatStreamingModel, reasoningEffort: nil, sessionId: sessionId ) @@ -44,9 +44,14 @@ extension ClaudeProvider { } func generateText(prompt: String) async throws -> (text: String, log: LLMCall) { + // One-off text requests (dashboard chat, ad-hoc prompts) intentionally + // ignore `defaultModel`: the user-selected Claude model is reserved for + // timeline-bound operations (transcription + card generation) so we + // don't accidentally let a "fast" model setting degrade richer reasoning + // tasks. Fall back to the same stable default as generateChatStreaming. try await generateText( prompt: prompt, - model: "sonnet", + model: resolvedChatStreamingModel, reasoningEffort: "high", disableTools: false ) diff --git a/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift b/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift index dc70866e8..2cc8b06d2 100644 --- a/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift +++ b/Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift @@ -19,7 +19,7 @@ extension ClaudeProvider { } let callStart = Date() - let modelConfiguration = Self.transcriptionModelConfiguration() + let modelConfiguration = resolvedTranscriptionModelConfiguration() let model = modelConfiguration.model let effort = modelConfiguration.reasoningEffort let preparedInput: ClaudePreparedTranscriptionInput diff --git a/Dayflow/Dayflow/Core/AI/ClaudeProvider.swift b/Dayflow/Dayflow/Core/AI/ClaudeProvider.swift index 4a8c86b47..73aedea8a 100644 --- a/Dayflow/Dayflow/Core/AI/ClaudeProvider.swift +++ b/Dayflow/Dayflow/Core/AI/ClaudeProvider.swift @@ -5,8 +5,15 @@ final class ClaudeProvider: AgentCLISupporting { var cliTool: ChatCLITool { .claude } let runner = ChatCLIProcessRunner() let config = ChatCLIConfigManager.shared + /// When non-nil, this overrides whatever the static `*ModelConfiguration` + /// methods would otherwise pick as the canonical Claude model. `LLMService` + /// populates it from `UserDefaults` (key: `llmClaudeModel`) so the model + /// the provider uses stays in sync with the user's Settings selection. + /// When nil, providers fall back to the hardcoded default. + let defaultModel: String? - init() { + init(defaultModel: String? = nil) { + self.defaultModel = defaultModel config.ensureWorkingDirectory() } @@ -53,6 +60,35 @@ final class ClaudeProvider: AgentCLISupporting { return first.adding(second) } + /// Resolved model for activity-card generation. Uses `defaultModel` when the + /// user has configured one in Settings, otherwise falls back to the + /// canonical Claude model returned by `activityCardModelConfiguration()`. + /// Reasoning effort is always taken from the static default because the + /// effort is a property of the Claude profile, not the model id. + func resolvedActivityCardModelConfiguration() -> ( + model: String, reasoningEffort: String? + ) { + let fallback = Self.activityCardModelConfiguration() + return (model: defaultModel ?? fallback.model, reasoningEffort: fallback.reasoningEffort) + } + + /// Resolved model for screenshot transcription. Same shape as + /// `resolvedActivityCardModelConfiguration()`; kept distinct so future per- + /// operation overrides (e.g. a faster transcription-only model) can be + /// added without touching the activity-card path. + func resolvedTranscriptionModelConfiguration() -> ( + model: String, reasoningEffort: String? + ) { + let fallback = Self.transcriptionModelConfiguration() + return (model: defaultModel ?? fallback.model, reasoningEffort: fallback.reasoningEffort) + } + + /// Model used for one-off `generateChatStreaming` calls (dashboard chat, + /// on-demand text). Falls back to a stable Claude default. + var resolvedChatStreamingModel: String { + defaultModel ?? Self.activityCardModelConfiguration().model + } + func validateSuccessfulClaudeProcess(_ run: ChatCLIRunResult) throws { guard run.exitCode == 0 else { let message = run.stderr.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift b/Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift index cf39b5168..12bf1e564 100644 --- a/Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift +++ b/Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift @@ -28,7 +28,7 @@ extension CodexProvider { let basePrompt = buildCardsPrompt(observations: observations, context: context) var actualPromptUsed = basePrompt - let modelConfiguration = Self.activityCardModelConfiguration() + let modelConfiguration = resolvedActivityCardModelConfiguration() let legacyModelConfiguration = Self.legacyActivityCardModelConfiguration() var model = modelConfiguration.model var effort = modelConfiguration.reasoningEffort diff --git a/Dayflow/Dayflow/Core/AI/CodexProvider+Text.swift b/Dayflow/Dayflow/Core/AI/CodexProvider+Text.swift index 1e7a486c1..031a23ae7 100644 --- a/Dayflow/Dayflow/Core/AI/CodexProvider+Text.swift +++ b/Dayflow/Dayflow/Core/AI/CodexProvider+Text.swift @@ -8,7 +8,7 @@ extension CodexProvider { tool: .codex, prompt: prompt, workingDirectory: config.workingDirectory, - model: "gpt-5.4", + model: resolvedChatStreamingModel, reasoningEffort: "low", sessionId: sessionId ) @@ -44,9 +44,14 @@ extension CodexProvider { } func generateText(prompt: String) async throws -> (text: String, log: LLMCall) { + // One-off text requests (dashboard chat, ad-hoc prompts) intentionally + // ignore `defaultModel`: the user-selected Codex model is reserved for + // timeline-bound operations (transcription + card generation) so we + // don't accidentally let a "fast" model setting degrade richer reasoning + // tasks. Fall back to the same stable default as generateChatStreaming. try await generateText( prompt: prompt, - model: "gpt-5.2", + model: resolvedChatStreamingModel, reasoningEffort: "high", disableTools: false ) diff --git a/Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift b/Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift index 0d19d8ff3..3a046f7eb 100644 --- a/Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift +++ b/Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift @@ -55,7 +55,7 @@ extension CodexProvider { ) } - let modelConfiguration = Self.transcriptionModelConfiguration() + let modelConfiguration = resolvedTranscriptionModelConfiguration() let legacyModelConfiguration = Self.legacyTranscriptionModelConfiguration() var model = modelConfiguration.model var effort = modelConfiguration.reasoningEffort diff --git a/Dayflow/Dayflow/Core/AI/CodexProvider.swift b/Dayflow/Dayflow/Core/AI/CodexProvider.swift index b087749c1..1bda93117 100644 --- a/Dayflow/Dayflow/Core/AI/CodexProvider.swift +++ b/Dayflow/Dayflow/Core/AI/CodexProvider.swift @@ -5,12 +5,50 @@ final class CodexProvider: AgentCLISupporting { var cliTool: ChatCLITool { .codex } let runner = ChatCLIProcessRunner() let config = ChatCLIConfigManager.shared + /// When non-nil, this overrides whatever the static `*ModelConfiguration` + /// methods would otherwise pick as the canonical Codex model. `LLMService` + /// populates it from `UserDefaults` (key: `llmCodexModel`) so the model the + /// provider uses stays in sync with the user's Settings selection. When + /// nil, providers fall back to the hardcoded default. + let defaultModel: String? - init() { + init(defaultModel: String? = nil) { + self.defaultModel = defaultModel config.ensureWorkingDirectory() } } +extension CodexProvider { + /// Resolved model for activity-card generation. Uses `defaultModel` when the + /// user has configured one in Settings, otherwise falls back to the + /// canonical Codex model returned by `activityCardModelConfiguration()`. + /// Reasoning effort is always taken from the static default because the + /// effort is a property of the Codex profile, not the model id. + func resolvedActivityCardModelConfiguration() -> ( + model: String, reasoningEffort: String? + ) { + let fallback = Self.activityCardModelConfiguration() + return (model: defaultModel ?? fallback.model, reasoningEffort: fallback.reasoningEffort) + } + + /// Resolved model for screenshot transcription. Same shape as + /// `resolvedActivityCardModelConfiguration()`; kept distinct so future per- + /// operation overrides (e.g. a faster transcription-only model) can be + /// added without touching the activity-card path. + func resolvedTranscriptionModelConfiguration() -> ( + model: String, reasoningEffort: String? + ) { + let fallback = Self.transcriptionModelConfiguration() + return (model: defaultModel ?? fallback.model, reasoningEffort: fallback.reasoningEffort) + } + + /// Model used for one-off `generateChatStreaming` calls (dashboard chat, + /// on-demand text). Falls back to a stable Codex default. + var resolvedChatStreamingModel: String { + defaultModel ?? Self.activityCardModelConfiguration().model + } +} + extension CodexProvider { static func shouldUseLegacyModel( after error: Error, diff --git a/Dayflow/Dayflow/Core/AI/LLMService.swift b/Dayflow/Dayflow/Core/AI/LLMService.swift index 4e9468184..94cd74980 100644 --- a/Dayflow/Dayflow/Core/AI/LLMService.swift +++ b/Dayflow/Dayflow/Core/AI/LLMService.swift @@ -190,6 +190,27 @@ final class LLMService: LLMServicing { } } + /// Reads the user-selected Chat CLI model from `UserDefaults` for the + /// given provider and returns a trimmed, non-empty value. Returns `nil` + /// when the key is unset or the stored value is whitespace-only so the + /// provider's `defaultModel` is `nil` and the static canonical default + /// is used. Mirrors v1's `makeChatCLIProvider` pass-through so Settings + /// selections actually reach the running provider. + private func resolvedChatCLIDefaultModel(for providerID: LLMProviderID) -> String? { + let key: String + switch providerID { + case .chatGPT: + key = "llmCodexModel" + case .claude: + key = "llmClaudeModel" + default: + return nil + } + guard let raw = UserDefaults.standard.string(forKey: key) else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private func noProviderError() -> NSError { NSError( domain: "LLMService", @@ -278,7 +299,7 @@ final class LLMService: LLMServicing { ), fallbackState: nil ) case .chatGPT: - let provider = CodexProvider() + let provider = CodexProvider(defaultModel: resolvedChatCLIDefaultModel(for: .chatGPT)) return ( actions: BatchProviderActions( transcribeScreenshots: provider.transcribeScreenshots, @@ -286,7 +307,7 @@ final class LLMService: LLMServicing { ), fallbackState: nil ) case .claude: - let provider = ClaudeProvider() + let provider = ClaudeProvider(defaultModel: resolvedChatCLIDefaultModel(for: .claude)) return ( actions: BatchProviderActions( transcribeScreenshots: provider.transcribeScreenshots, @@ -586,7 +607,7 @@ final class LLMService: LLMServicing { generateTextStreaming: nil ) case .chatGPT: - let provider = CodexProvider() + let provider = CodexProvider(defaultModel: resolvedChatCLIDefaultModel(for: .chatGPT)) return TextProviderActions( generateText: { prompt in try await provider.generateText(prompt: prompt) @@ -594,7 +615,7 @@ final class LLMService: LLMServicing { generateTextStreaming: provider.generateTextStreaming ) case .claude: - let provider = ClaudeProvider() + let provider = ClaudeProvider(defaultModel: resolvedChatCLIDefaultModel(for: .claude)) return TextProviderActions( generateText: { prompt in try await provider.generateText(prompt: prompt) @@ -1247,15 +1268,17 @@ final class LLMService: LLMServicing { history: request.history ) case .codex: - return CodexProvider().generateChatStreaming( - prompt: request.prompt, - sessionId: request.sessionId - ) + return CodexProvider(defaultModel: resolvedChatCLIDefaultModel(for: .chatGPT)) + .generateChatStreaming( + prompt: request.prompt, + sessionId: request.sessionId + ) case .claude: - return ClaudeProvider().generateChatStreaming( - prompt: request.prompt, - sessionId: request.sessionId - ) + return ClaudeProvider(defaultModel: resolvedChatCLIDefaultModel(for: .claude)) + .generateChatStreaming( + prompt: request.prompt, + sessionId: request.sessionId + ) } } } diff --git a/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift b/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift index 87db20c8e..e179ce361 100644 --- a/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift +++ b/Dayflow/Dayflow/Views/UI/CanvasActivityCard.swift @@ -19,6 +19,11 @@ struct CanvasActivityCard: View { let isSelected: Bool let isSystemCategory: Bool let isBackupGenerated: Bool + /// Compact "Provider · Model" string shown in the card footer so the + /// user can see at a glance which model produced this card (e.g. + /// "Gemini · 2.5 Pro", "Claude · Sonnet"). Optional because older + /// saved cards don't carry the metadata; when nil the badge is hidden. + let providerBadge: String? let onTap: () -> Void // Raw values for pattern matching (may contain paths) let faviconPrimaryRaw: String? @@ -146,19 +151,29 @@ struct CanvasActivityCard: View { Spacer() - HStack(spacing: 6) { - if isBackupGenerated { - backupIndicator - } + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 6) { + if isBackupGenerated { + backupIndicator + } - Text(time) - .font( - Font.custom("Figtree", size: secondaryFontSize) - .weight(.medium) - ) - .foregroundColor(style.time) - .lineLimit(1) - .truncationMode(.tail) + Text(time) + .font( + Font.custom("Figtree", size: secondaryFontSize) + .weight(.medium) + ) + .foregroundColor(style.time) + .lineLimit(1) + .truncationMode(.tail) + } + if let badge = providerBadge, !badge.isEmpty { + Text(badge) + .font(.custom("Figtree", size: 9)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .help("This card was produced by \(badge)") + } } } } diff --git a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift index 868f5d5b3..7b1da95ac 100644 --- a/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift +++ b/Dayflow/Dayflow/Views/UI/CanvasTimelineDataView.swift @@ -109,6 +109,39 @@ struct CanvasTimelineDataView: View { // between triggers and keeps the body's inline closures tiny (fixes a // Swift type-checker timeout that appeared when each closure inlined its // own copy of this calculation). + /// Compact "provider · model" string rendered on each card so the user + /// always sees which provider/model produced the card. Returns `nil` when + /// both fields are missing (older saved cards) so the card simply omits + /// the badge instead of showing a placeholder. Provider id is mapped to a + /// human label here so storage-level identifiers like "chatgpt_claude" + /// surface as "Claude" or "ChatGPT" based on the model name. + private func providerBadge(for activity: TimelineActivity) -> String? { + let rawProvider = (activity.providerId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let rawModel = (activity.modelId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if rawProvider.isEmpty && rawModel.isEmpty { return nil } + + let providerLabel: String + switch rawProvider { + case "gemini": providerLabel = "Gemini" + case "minimax": providerLabel = "MiniMax" + case "ollama": providerLabel = "Local" + case "chatgpt_claude": + // For the unified ChatGPT/Claude CLI we surface the specific tool + // based on which model produced the card so users see "Claude · + // Sonnet" rather than the generic "chatgpt_claude" raw id. + if rawModel.lowercased().contains("claude") { providerLabel = "Claude" } + else if rawModel.lowercased().contains("gpt") + || rawModel.lowercased().contains("codex") + { providerLabel = "ChatGPT" } + else { providerLabel = "CLI" } + case "dayflow": providerLabel = "Dayflow Pro" + default: providerLabel = rawProvider.isEmpty ? "AI" : rawProvider + } + + if rawModel.isEmpty { return providerLabel } + return "\(providerLabel) · \(rawModel)" + } + private func nowCenteredTargetHourIndex() -> Int { let currentHour = Calendar.current.component(.hour, from: Date()) let hoursSince4AM = currentHour >= 4 ? currentHour - 4 : (24 - 4) + currentHour @@ -331,6 +364,7 @@ struct CanvasTimelineDataView: View { isSystemCategory: item.categoryName.trimmingCharacters(in: .whitespacesAndNewlines) .caseInsensitiveCompare("System") == .orderedSame, isBackupGenerated: item.activity.isBackupGenerated == true, + providerBadge: providerBadge(for: item.activity), onTap: { if selectedCardId == item.id { clearSelection() diff --git a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift index ae9215c8a..129f59a9d 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/TimelineActivityLoader.swift @@ -175,7 +175,9 @@ enum TimelineActivityLoader { videoSummaryURL: card.videoSummaryURL, screenshot: nil, appSites: card.appSites, - isBackupGenerated: card.isBackupGenerated + isBackupGenerated: card.isBackupGenerated, + providerId: card.providerId, + modelId: card.modelId ) ) } diff --git a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift index 6154718aa..a5ab0583f 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/WeekTimelineGridPreview.swift @@ -136,7 +136,9 @@ private struct WeekTimelineHoverPrototypeHarness: View { videoSummaryURL: nil, screenshot: nil, appSites: spec.favicon.map { AppSites(primary: $0, secondary: nil) }, - isBackupGenerated: false + isBackupGenerated: false, + providerId: nil, + modelId: nil ) let yPos = CGFloat(spec.startMinutes) * ppm + 1 diff --git a/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift b/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift index 457c6c2ea..4b26a34fe 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineDataModels.swift @@ -25,6 +25,13 @@ struct TimelineActivity: Identifiable { let screenshot: NSImage? let appSites: AppSites? let isBackupGenerated: Bool? + /// Which Dayflow provider produced this activity ("gemini", "minimax", + /// "ollama", "chatgpt_claude", "dayflow", etc.). Optional because older + /// saved cards don't carry the metadata. + let providerId: String? + /// Which model the provider used (e.g. "MiniMax-M3", "gpt-5.6-luna", + /// "claude-sonnet"). Optional for the same reason as `providerId`. + let modelId: String? static func stableId( recordId: Int64?, batchId: Int64?, startTime: Date, endTime: Date, title: String, @@ -64,7 +71,9 @@ struct TimelineActivity: Identifiable { videoSummaryURL: videoSummaryURL, screenshot: screenshot, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: providerId, + modelId: modelId ) } @@ -84,7 +93,9 @@ struct TimelineActivity: Identifiable { videoSummaryURL: videoSummaryURL, screenshot: screenshot, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: providerId, + modelId: modelId ) } @@ -104,7 +115,9 @@ struct TimelineActivity: Identifiable { videoSummaryURL: newVideoSummaryURL, screenshot: screenshot, appSites: appSites, - isBackupGenerated: isBackupGenerated + isBackupGenerated: isBackupGenerated, + providerId: providerId, + modelId: modelId ) } } diff --git a/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift b/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift index c05356e91..5cb9752b0 100644 --- a/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift +++ b/Dayflow/Dayflow/Views/UI/TimelineReviewTypes.swift @@ -235,7 +235,9 @@ func makeTimelineActivities(from cards: [TimelineCard], for date: Date) videoSummaryURL: card.videoSummaryURL, screenshot: nil, appSites: card.appSites, - isBackupGenerated: card.isBackupGenerated + isBackupGenerated: card.isBackupGenerated, + providerId: card.providerId, + modelId: card.modelId )) } diff --git a/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift b/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift index 77d09f8a4..bc100bead 100644 --- a/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift +++ b/Dayflow/DayflowTests/TimelineActivityLoaderTests.swift @@ -74,7 +74,9 @@ final class TimelineActivityLoaderTests: XCTestCase { videoSummaryURL: nil, screenshot: nil, appSites: nil, - isBackupGenerated: false + isBackupGenerated: false, + providerId: nil, + modelId: nil ) } From 27c3c68f6523b1b6fd359232eca2736cdb2d0cf4 Mon Sep 17 00:00:00 2001 From: M3NT1 Date: Sun, 19 Jul 2026 21:46:13 +0200 Subject: [PATCH 3/3] fix: surface screen-recording permission notice on the timeline The toast that warns the user about missing screen-recording access was gated on 'getSavedPreference() == true || appState.isRecording', but the recorder forces isRecording off the moment it detects the permission is missing, and getSavedPreference() is nil for users who have never explicitly toggled it. Net effect: the notice never surfaced for the exact case it was meant to alert on, and the user had no UI signal that recording was broken (the Resume button appeared to do nothing). Switch the guard to the 'didOnboard' UserDefaults flag so the notice appears whenever the user has completed onboarding and the permission is missing. Also re-evaluate the notice on every tab change to .timeline so a user who lands on the timeline after dismissing elsewhere still sees it (the session-dismiss flag prevents spam). --- Dayflow/Dayflow/Views/UI/MainView/Layout.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Dayflow/Dayflow/Views/UI/MainView/Layout.swift b/Dayflow/Dayflow/Views/UI/MainView/Layout.swift index e61f5b071..1879254ef 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/Layout.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/Layout.swift @@ -222,6 +222,11 @@ extension MainView { } updateCardsToReviewCount() loadWeeklyTrackedMinutes() + // Re-evaluate the screen-recording permission notice whenever the user + // lands on the timeline. The notice is a session-dismissible toast and + // won't re-show once `didDismissScreenRecordingPermissionNoticeThisSession` + // is true, so this is safe to call on every timeline entry. + showScreenRecordingNoticeIfNeeded() } else { showTimelineReview = false } @@ -315,7 +320,16 @@ extension MainView { showScreenRecordingPermissionNotice = false return } - guard AppState.shared.getSavedPreference() == true || appState.isRecording else { return } + + // Show the notice once onboarding is finished and the user has lost + // (or never granted) screen-recording access. The previous guard also + // required `getSavedPreference() == true || appState.isRecording`, but + // `isRecording` is forced off the moment the recorder detects the + // permission is missing, and `getSavedPreference()` is `nil` for users + // who have never explicitly toggled it — so the notice never surfaced + // for exactly the case we need to alert on. + let didOnboard = UserDefaults.standard.bool(forKey: "didOnboard") + guard didOnboard else { return } withAnimation(.spring(response: 0.28, dampingFraction: 0.9)) { showScreenRecordingPermissionNotice = true