Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
474 changes: 474 additions & 0 deletions Dayflow/Dayflow/Core/AI/ChatCLIModelCatalog.swift

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions Dayflow/Dayflow/Core/AI/ClaudeModelPreference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// ClaudeModelPreference.swift
// Dayflow
//
// Persists the user's choice of Claude model. Mirrors
// `GeminiModelPreference` so the Settings → Providers tab can show a
// picker with the same UX as the Gemini one.
//
// The `rawValue` is the alias the Claude CLI accepts (e.g. `sonnet`),
// while `displayName` is the user-facing label that includes the
// resolved full model name (e.g. "Claude Sonnet 5"). The catalog
// (`ChatCLIModelCatalog`) is what populates `displayName` — at minimum
// we know the alias resolves to a real model on the user's account,
// because we probed the CLI to build the list in the first place.
//
// The stored preference is the *alias* (not the full name) because
// that's what the CLI's `--model` flag actually accepts and because
// aliases track the user's account to the latest release of that
// model family (e.g. `sonnet` will follow Sonnet 5 → Sonnet 5.5
// without us having to bump a stored version).

import Foundation

enum ClaudeModel: String, Codable, CaseIterable {
// Aliases the Claude CLI accepts as `--model` values. Each one
// resolves to the latest model in that family on the user's account.
// The list is the same set the CLI probe iterates over
// (`ChatCLIModelCatalog.knownClaudeAliases`); keeping them in sync
// matters because `ClaudeModel(rawValue:)` is how we read a probed
// result back into the picker.
case sonnet
case opus
case fable
case haiku

/// User-facing label for the picker. The `displayName` baked in
/// here is a *baseline* — the Settings UI prefers the live
/// `displayName` returned by `ChatCLIModelCatalog` so the user
/// always sees the resolved full model name (e.g. "Claude Sonnet 5")
/// rather than a hard-coded string.
var displayName: String {
switch self {
case .sonnet: return "Claude Sonnet"
case .opus: return "Claude Opus"
case .fable: return "Claude Fable"
case .haiku: return "Claude Haiku"
}
}

/// One-line hint shown under the picker — keeps users oriented
/// between quality/speed tradeoffs without overwhelming the UI.
var hint: String {
switch self {
case .sonnet: return "Balanced — best default for most users"
case .opus: return "Highest quality, slower and pricier"
case .fable: return "Experimental — newest reasoning family"
case .haiku: return "Fastest — lower quality summaries"
}
}
}

struct ClaudeModelPreference: Codable {
// Key bump intentionally hard-resets existing users to the new
// ordering, matching the `GeminiModelPreference` convention.
private static let storageKey = "claudeSelectedModel_v1"

let primary: ClaudeModel

static let `default` = ClaudeModelPreference(primary: .sonnet)

static func load(from defaults: UserDefaults = .standard) -> ClaudeModelPreference {
if let data = defaults.data(forKey: storageKey),
let preference = try? JSONDecoder().decode(ClaudeModelPreference.self, from: data)
{
return preference
}

let preference = ClaudeModelPreference.default
preference.save(to: defaults)
return preference
}

func save(to defaults: UserDefaults = .standard) {
if let data = try? JSONEncoder().encode(self) {
defaults.set(data, forKey: Self.storageKey)
}
}
}
7 changes: 6 additions & 1 deletion Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ extension ClaudeProvider {
static func activityCardModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
(model: "claude-sonnet", reasoningEffort: "low")
// Mirrors `transcriptionModelConfiguration` — we always pass
// the user's selected alias to the CLI rather than a hard-coded
// model name. The Settings → Providers tab is what writes this
// preference; the catalog at `ChatCLIModelCatalog` powers the
// picker with the live display names.
(model: ClaudeModelPreference.load().primary.rawValue, reasoningEffort: "low")
}

func generateActivityCards(
Expand Down
11 changes: 10 additions & 1 deletion Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ extension ClaudeProvider {
static func transcriptionModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
(model: "claude-sonnet", reasoningEffort: "low")
// The Claude CLI's `--model` flag only accepts an alias
// (`sonnet`, `opus`, `fable`, `haiku`, …) or a full model name
// like `claude-fable-5`. Anything else returns "It may not exist
// or you may not have access to it" → exit 1. We persist the
// *alias* in `ClaudeModelPreference` because aliases track the
// latest release of each family automatically — Sonnet today
// becomes Sonnet 5.5 tomorrow without us bumping a stored
// version. The user picks the alias from the Settings → Providers
// tab; we send that exact string to the CLI.
(model: ClaudeModelPreference.load().primary.rawValue, reasoningEffort: "low")
}

func transcribeScreenshots(
Expand Down
70 changes: 70 additions & 0 deletions Dayflow/Dayflow/Core/AI/CodexModelPreference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// CodexModelPreference.swift
// Dayflow
//
// Persists the user's choice of Codex (ChatGPT) model. Mirrors
// `ClaudeModelPreference` so the Settings → Providers tab can show a
// picker with the same UX as the Gemini/Claude one.
//
// The `rawValue` is the model id the Codex CLI accepts as `--model`.
// The list is small and stable — OpenAI's flagship + a small/fast
// variant — and is what the CLI probe iterates over
// (`ChatCLIModelCatalog.knownCodexAliases`).

import Foundation

enum CodexModel: String, Codable, CaseIterable {
// Two distinct "5.6" variants existed in the legacy code (one for
// transcription, one for card generation). We expose them as
// separate picker rows so users who care about the difference can
// choose, but the default is the variant that was previously the
// hard-coded card-generation choice.
case gpt56Luna = "gpt-5.6-luna"
case gpt56Sol = "gpt-5.6-sol"
case gpt54 = "gpt-5.4"
case gpt54Mini = "gpt-5.4-mini"

var displayName: String {
switch self {
case .gpt56Luna: return "GPT 5.6 (luna)"
case .gpt56Sol: return "GPT 5.6 (sol)"
case .gpt54: return "GPT 5.4"
case .gpt54Mini: return "GPT 5.4 mini"
}
}

var hint: String {
switch self {
case .gpt56Luna: return "Latest — tuned for transcription"
case .gpt56Sol: return "Latest — tuned for card generation"
case .gpt54: return "Previous generation — solid default"
case .gpt54Mini: return "Fast and cheap — lighter summaries"
}
}
}

struct CodexModelPreference: Codable {
private static let storageKey = "codexSelectedModel_v1"

let primary: CodexModel

static let `default` = CodexModelPreference(primary: .gpt56Luna)

static func load(from defaults: UserDefaults = .standard) -> CodexModelPreference {
if let data = defaults.data(forKey: storageKey),
let preference = try? JSONDecoder().decode(CodexModelPreference.self, from: data)
{
return preference
}

let preference = CodexModelPreference.default
preference.save(to: defaults)
return preference
}

func save(to defaults: UserDefaults = .standard) {
if let data = try? JSONEncoder().encode(self) {
defaults.set(data, forKey: Self.storageKey)
}
}
}
7 changes: 5 additions & 2 deletions Dayflow/Dayflow/Core/AI/CodexProvider+ActivityCards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,16 @@ extension CodexProvider {
static func activityCardModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
return (model: "gpt-5.6-sol", reasoningEffort: "low")
// Mirrors `transcriptionModelConfiguration` — picks up the
// user's selection from `CodexModelPreference` rather than
// hard-coding a model name.
return (model: CodexModelPreference.load().primary.rawValue, reasoningEffort: "low")
}

static func legacyActivityCardModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
return (model: "gpt-5.4", reasoningEffort: "low")
return (model: CodexModel.gpt54.rawValue, reasoningEffort: "low")
}

private func codexRunResultFromStreamingError(
Expand Down
10 changes: 8 additions & 2 deletions Dayflow/Dayflow/Core/AI/CodexProvider+Transcription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ extension CodexProvider {
static func transcriptionModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
return (model: "gpt-5.6-luna", reasoningEffort: "low")
// Mirrors `ClaudeProvider.transcriptionModelConfiguration` — the
// primary model is whatever the user picked in Settings → Providers,
// loaded from `CodexModelPreference`. The legacy fallback stays
// hard-coded because the picker only exposes the primary slot; if
// the user's selected model errors out, Dayflow falls back to a
// known-good previous-generation model.
return (model: CodexModelPreference.load().primary.rawValue, reasoningEffort: "low")
}

static func legacyTranscriptionModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
return (model: "gpt-5.4-mini", reasoningEffort: "low")
return (model: CodexModel.gpt54Mini.rawValue, reasoningEffort: "low")
}

func transcribeScreenshots(
Expand Down
68 changes: 64 additions & 4 deletions Dayflow/Dayflow/Core/AI/LLMService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,47 @@ final class LLMService: LLMServicing {
providerID.providerLabel
}

/// Returns the model id the provider should be stamped with on
/// generated cards. Mirrors how `providerLabel` is sourced from the
/// `LLMProviderID`, but goes one level deeper to read the actual model
/// the user has configured (Gemini primary preference, Ollama model id
/// in `UserDefaults`, ChatGPT/Claude CLI model id, etc.). Returns
/// `nil` for providers that don't expose a model concept (Dayflow Pro)
/// or where the user hasn't picked one yet, so the UI badge can fall
/// back to a provider-only label.
private func providerModelId(for providerID: LLMProviderID) -> String? {
switch providerID {
case .gemini:
// `GeminiModelPreference` always carries a primary; reading the
// raw value gives us the user-facing id without touching the
// provider's internal fallback chain.
return GeminiModelPreference.load().primary.rawValue
case .local:
let trimmed =
UserDefaults.standard.string(forKey: "llmLocalModelId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
case .openAICompatible:
let trimmed =
OpenAICompatiblePreferences.load()?.modelID
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
case .chatGPT:
// Read the user's pick from `CodexModelPreference` (the
// Settings → Providers tab writes this). We pass the raw
// alias through to the picker badge so the user sees the
// model id they selected, not a stale display name.
return CodexModelPreference.load().primary.rawValue
case .claude:
// Read the user's pick from `ClaudeModelPreference` (the
// Settings → Providers tab writes this). The CLI accepts
// these aliases natively — `sonnet` → latest Sonnet release.
return ClaudeModelPreference.load().primary.rawValue
case .dayflow:
return nil
}
}

private func noProviderError() -> NSError {
NSError(
domain: "LLMService",
Expand Down Expand Up @@ -838,6 +879,13 @@ final class LLMService: LLMServicing {
// Note: card generation log is not persisted per-batch yet

// Replace old cards with new ones in the time range
// `activeContext.id` reflects the provider that actually
// produced these cards (primary or fallback), and
// `providerModelId(for:)` reads the user-configured model. We
// stamp both onto every card so the UI can render a
// "Provider · Model" badge without re-running the analysis.
let activeProviderId = activeContext.id.providerLabel
let activeModelId = providerModelId(for: activeContext.id)
let (insertedCardIds, deletedVideoPaths) = StorageManager.shared
.replaceTimelineCardsInRange(
from: windowStartTime,
Expand All @@ -853,7 +901,9 @@ final class LLMService: LLMServicing {
detailedSummary: card.detailedSummary,
distractions: card.distractions,
appSites: card.appSites,
isBackupGenerated: isBackupGenerated ? true : nil
isBackupGenerated: isBackupGenerated ? true : nil,
providerId: activeProviderId,
modelId: activeModelId
)
},
batchId: batchId
Expand Down Expand Up @@ -937,11 +987,18 @@ final class LLMService: LLMServicing {
let batchStartDate = Date(timeIntervalSince1970: TimeInterval(batchStartTs))
let batchEndDate = Date(timeIntervalSince1970: TimeInterval(batchEndTs))

// Stamp the error card with whichever provider the user has
// configured. We don't have an `activeContext` here because the
// failure could have happened during initialization, so fall
// back to the primary provider's identity — that's the one the
// user is going to want to retry against anyway.
let errorCard = createErrorCard(
batchId: batchId,
batchStartTime: batchStartDate,
batchEndTime: batchEndDate,
error: error
error: error,
providerId: primaryProviderID.providerLabel,
modelId: providerModelId(for: primaryProviderID)
)

// Replace any existing cards in this time range with the error card
Expand Down Expand Up @@ -978,7 +1035,8 @@ final class LLMService: LLMServicing {
}

private func createErrorCard(
batchId: Int64, batchStartTime: Date, batchEndTime: Date, error: Error
batchId: Int64, batchStartTime: Date, batchEndTime: Date, error: Error,
providerId: String?, modelId: String?
) -> TimelineCardShell {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm a"
Expand Down Expand Up @@ -1006,7 +1064,9 @@ final class LLMService: LLMServicing {
detailedSummary:
"Error details: \(error.localizedDescription)\n\nThis recording batch (ID: \(batchId)) failed during AI processing. The original video files are preserved and can be reprocessed by retrying from Settings. Common causes include network issues, API rate limits, or temporary service outages.",
distractions: nil,
appSites: nil
appSites: nil,
providerId: providerId,
modelId: modelId
)
}

Expand Down
Loading