Skip to content

Commit 6d3d0c3

Browse files
committed
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
1 parent c5ae698 commit 6d3d0c3

24 files changed

Lines changed: 3351 additions & 19 deletions

Dayflow/Dayflow/Core/AI/DailyRecapModels.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ enum DailyRecapProvider: String, Codable, CaseIterable, Sendable {
5656
return .local
5757
case .openAICompatible:
5858
return .none
59+
case .minimax:
60+
return .none
5961
}
6062
}
6163

Dayflow/Dayflow/Core/AI/LLMService.swift

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,21 @@ final class LLMService: LLMServicing {
183183
return OllamaProvider(openAICompatible: runtimeConfiguration)
184184
}
185185

186+
private func makeMiniMaxProvider() -> MiniMaxProvider? {
187+
guard let key = KeychainManager.shared.retrieve(for: MiniMaxProvider.keychainKey),
188+
!key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
189+
else {
190+
print("❌ [LLMService] MiniMax provider unavailable: missing API key")
191+
return nil
192+
}
193+
return MiniMaxProvider()
194+
}
195+
186196
private func providerLabel(for providerID: LLMProviderID) -> String {
187197
providerID.providerLabel
188198
}
189199

190-
/// Returns the model id the provider should be stamped with on
200+
/// Returns the model id the provider should be stamped with on
191201
/// generated cards. Mirrors how `providerLabel` is sourced from the
192202
/// `LLMProviderID`, but goes one level deeper to read the actual model
193203
/// the user has configured (Gemini primary preference, Ollama model id
@@ -223,6 +233,8 @@ final class LLMService: LLMServicing {
223233
// Settings → Providers tab writes this). The CLI accepts
224234
// these aliases natively — `sonnet` → latest Sonnet release.
225235
return ClaudeModelPreference.load().primary.rawValue
236+
case .minimax:
237+
return MiniMaxModelPreference.load().modelId
226238
case .dayflow:
227239
return nil
228240
}
@@ -331,6 +343,20 @@ final class LLMService: LLMServicing {
331343
generateActivityCards: provider.generateActivityCards
332344
), fallbackState: nil
333345
)
346+
case .minimax:
347+
guard let provider = makeMiniMaxProvider() else { throw noProviderError() }
348+
return (
349+
actions: BatchProviderActions(
350+
transcribeScreenshots: { [provider] screenshots, batchStartTime, batchId in
351+
try await provider.transcribeScreenshots(
352+
screenshots, batchStartTime: batchStartTime, batchId: batchId)
353+
},
354+
generateActivityCards: { [provider] observations, context, batchId in
355+
try await provider.generateActivityCards(
356+
observations: observations, context: context, batchId: batchId)
357+
}
358+
), fallbackState: nil
359+
)
334360
}
335361
}
336362

@@ -625,6 +651,14 @@ final class LLMService: LLMServicing {
625651
},
626652
generateTextStreaming: provider.generateTextStreaming
627653
)
654+
case .minimax:
655+
guard let provider = makeMiniMaxProvider() else { throw noProviderError() }
656+
return TextProviderActions(
657+
generateText: { prompt in
658+
try await provider.generateText(prompt: prompt)
659+
},
660+
generateTextStreaming: nil
661+
)
628662
}
629663
}
630664

@@ -998,7 +1032,7 @@ final class LLMService: LLMServicing {
9981032
distractions: card.distractions,
9991033
appSites: card.appSites,
10001034
isBackupGenerated: isBackupGenerated ? true : nil,
1001-
providerId: activeProviderId,
1035+
providerId: activeProviderId,
10021036
modelId: activeModelId
10031037
)
10041038
},

Dayflow/Dayflow/Core/AI/LLMTypes.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ enum LLMProviderID: String, Codable, CaseIterable {
8888
case claude
8989
case openAICompatible = "openai_compatible"
9090
case local
91+
case minimax
9192

9293
var analyticsName: String {
9394
switch self {
@@ -101,6 +102,8 @@ enum LLMProviderID: String, Codable, CaseIterable {
101102
return "openai_compatible"
102103
case .local:
103104
return "ollama"
105+
case .minimax:
106+
return "minimax"
104107
}
105108
}
106109

@@ -113,6 +116,8 @@ enum LLMProviderID: String, Codable, CaseIterable {
113116
case .openAICompatible: return "openai_compatible"
114117
case .local:
115118
return "local"
119+
case .minimax:
120+
return "minimax"
116121
}
117122
}
118123
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//
2+
// MiniMaxModelPreference.swift
3+
// Dayflow
4+
//
5+
// Persisted preference for the active MiniMax model. Mirrors the shape of
6+
// `GeminiModelPreference` so the settings UI can drop the same component in.
7+
//
8+
9+
import Foundation
10+
11+
struct MiniMaxModelPreference: Codable, Equatable {
12+
/// Wire-format model id sent in chat completion requests.
13+
var modelId: String
14+
15+
/// Whether the user has selected a non-default model.
16+
var hasUserOverride: Bool
17+
18+
static let `default` = MiniMaxModelPreference(
19+
modelId: MiniMaxProvider.defaultModelId,
20+
hasUserOverride: false
21+
)
22+
23+
static let storageKey = "minimaxModelPreference"
24+
25+
static func load(from defaults: UserDefaults = .standard) -> MiniMaxModelPreference {
26+
guard let data = defaults.data(forKey: storageKey),
27+
let decoded = try? JSONDecoder().decode(MiniMaxModelPreference.self, from: data)
28+
else {
29+
return .default
30+
}
31+
return decoded
32+
}
33+
34+
func persist(to defaults: UserDefaults = .standard) {
35+
guard let data = try? JSONEncoder().encode(self) else { return }
36+
defaults.set(data, forKey: Self.storageKey)
37+
}
38+
39+
mutating func setModelId(_ newValue: String) {
40+
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
41+
guard !trimmed.isEmpty else { return }
42+
self.modelId = trimmed
43+
self.hasUserOverride = (trimmed != MiniMaxProvider.defaultModelId)
44+
}
45+
46+
mutating func reset() {
47+
self = .default
48+
}
49+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
//
2+
// MiniMaxPromptPreferences.swift
3+
// Dayflow
4+
//
5+
// User-overridable prompt blocks for the MiniMax provider. Defaults are
6+
// intentionally aligned with the Ollama prompt defaults so behaviour stays
7+
// consistent across providers. Users can customise either block independently.
8+
//
9+
10+
import Foundation
11+
12+
struct MiniMaxPromptOverrides: Codable, Equatable {
13+
var summaryBlock: String?
14+
var titleBlock: String?
15+
16+
var isEmpty: Bool {
17+
[summaryBlock, titleBlock].allSatisfy { value in
18+
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
19+
return trimmed.isEmpty
20+
}
21+
}
22+
}
23+
24+
enum MiniMaxPromptPreferences {
25+
private static let overridesKey = "minimaxPromptOverrides"
26+
private static let store = UserDefaults.standard
27+
28+
static func load() -> MiniMaxPromptOverrides {
29+
guard let data = store.data(forKey: overridesKey) else {
30+
return MiniMaxPromptOverrides()
31+
}
32+
guard let overrides = try? JSONDecoder().decode(MiniMaxPromptOverrides.self, from: data)
33+
else {
34+
return MiniMaxPromptOverrides()
35+
}
36+
return overrides
37+
}
38+
39+
static func save(_ overrides: MiniMaxPromptOverrides) {
40+
guard let data = try? JSONEncoder().encode(overrides) else { return }
41+
store.set(data, forKey: overridesKey)
42+
}
43+
44+
static func reset() {
45+
store.removeObject(forKey: overridesKey)
46+
}
47+
}
48+
49+
enum MiniMaxPromptDefaults {
50+
static let summaryBlock = """
51+
SUMMARY GUIDELINES:
52+
- Write in first person without using "I" (like a personal journal entry)
53+
- 2-3 sentences maximum
54+
- Include specific details (app names, search topics, etc.)
55+
- Natural, conversational tone
56+
57+
GOOD EXAMPLES:
58+
"Managed Mac system preferences focusing on software updates and accessibility settings. Browsed Chrome searching for iPhone wireless charging info while
59+
checking Twitter and Slack messages."
60+
61+
"Configured GitHub Actions pipeline for automated testing. Quick Slack check interrupted focus, then back to debugging deployment issues."
62+
63+
"Researched React performance optimization techniques in Chrome, reading articles about useMemo patterns. Switched between documentation tabs and took notes in
64+
Notion about component re-rendering."
65+
66+
"Updated Xcode project dependencies and resolved build errors in SwiftUI views. Tested app on simulator while responding to client messages about timeline
67+
changes."
68+
69+
"Browsed Instagram and TikTok while listening to Spotify playlist. Responded to personal messages on WhatsApp about weekend plans."
70+
71+
BAD EXAMPLES:
72+
- "The user did various computer activities" (too vague, wrong perspective, never say the user)
73+
- "I was working on my computer doing different tasks" (uses "I", not specific)
74+
- "Spent time on multiple applications and websites" (generic, no details)
75+
"""
76+
77+
static let titleBlock = """
78+
Write one activity title for a 15-minute window using ONLY the observations.
79+
Rules:
80+
- 5-10 words, natural and specific, single line
81+
- Choose the dominant activity (most time), not necessarily the first
82+
- Ignore brief interruptions (<3 minutes)
83+
- Include a second activity only if both take ~5+ minutes
84+
- If 3+ unrelated activities appear, output exactly: "Scattered apps and sites"
85+
- Prefer proper nouns/topics (Bookface, Claude, League of Legends, Paul Graham, etc.)
86+
- Never use: worked on, looked at, handled, various, some, multiple, browsing, browse, multitasking, tabs, brief, quick, short
87+
- Do NOT use the word "browsing"; use "scrolling" or "reading" instead
88+
- Avoid long lists; no more than one conjunction
89+
- Return only the title text (no quotes, no JSON)
90+
"""
91+
}
92+
93+
struct MiniMaxPromptSections {
94+
let summary: String
95+
let title: String
96+
97+
init(overrides: MiniMaxPromptOverrides) {
98+
self.summary = MiniMaxPromptSections.compose(
99+
defaultBlock: MiniMaxPromptDefaults.summaryBlock, custom: overrides.summaryBlock)
100+
self.title = MiniMaxPromptSections.compose(
101+
defaultBlock: MiniMaxPromptDefaults.titleBlock, custom: overrides.titleBlock)
102+
}
103+
104+
private static func compose(defaultBlock: String, custom: String?) -> String {
105+
let trimmed = custom?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
106+
return trimmed.isEmpty ? defaultBlock : trimmed
107+
}
108+
}

0 commit comments

Comments
 (0)