Skip to content
Closed
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
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
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
135 changes: 80 additions & 55 deletions Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,52 @@ import GRDB
import Sentry

extension StorageManager {
/// Parsed view of a `timeline_cards.metadata` JSON column. Centralized
/// here so every reader (`fetchTimelineCards(forBatch:)`,
/// `fetchTimelineCards(forDay:)`, `fetchTimelineCardsByTimeRange`,
/// etc.) pulls the same field set — important because earlier rows may
/// carry either the new envelope or a legacy bare `[Distraction]`
/// array.
fileprivate struct ParsedTimelineMetadata {
let distractions: [Distraction]?
let appSites: AppSites?
let isBackupGenerated: Bool?
let providerId: String?
let modelId: String?
}

fileprivate static func parseMetadata(
_ metadataString: String?, using decoder: JSONDecoder
) -> ParsedTimelineMetadata {
guard
let metadataString,
let jsonData = metadataString.data(using: .utf8)
else {
return ParsedTimelineMetadata(
distractions: nil, appSites: nil, isBackupGenerated: nil,
providerId: nil, modelId: nil)
}
if let meta = try? decoder.decode(TimelineMetadata.self, from: jsonData) {
return ParsedTimelineMetadata(
distractions: meta.distractions,
appSites: meta.appSites,
isBackupGenerated: meta.isBackupGenerated,
providerId: meta.providerId,
modelId: meta.modelId)
}
// Legacy format: the column was a bare [Distraction] array before
// the metadata envelope existed. Keep the distractions, leave
// everything else nil so the UI can render the card correctly.
if let legacy = try? decoder.decode([Distraction].self, from: jsonData) {
return ParsedTimelineMetadata(
distractions: legacy, appSites: nil, isBackupGenerated: nil,
providerId: nil, modelId: nil)
}
return ParsedTimelineMetadata(
distractions: nil, appSites: nil, isBackupGenerated: nil,
providerId: nil, modelId: nil)
}

func saveTimelineCardShell(batchId: Int64, card: TimelineCardShell) -> Int64? {
let encoder = JSONEncoder()
var lastId: Int64? = nil
Expand Down Expand Up @@ -84,7 +130,9 @@ extension StorageManager {
distractions: card.distractions,
appSites: card.appSites,
isBackupGenerated: card.isBackupGenerated,
idle: card.idleMetadata
idle: card.idleMetadata,
providerId: card.providerId,
modelId: card.modelId
)
let metadataString: String? = (try? encoder.encode(meta)).flatMap {
String(data: $0, encoding: .utf8)
Expand Down Expand Up @@ -248,7 +296,13 @@ extension StorageManager {
distractions: nil,
appSites: AppSites(primary: "dayflow.so", secondary: nil),
isBackupGenerated: nil,
idle: nil
idle: nil,
// Onboarding cards are static — they're written by the app to
// give the user a sample card on first launch, not produced by
// any LLM. Leaving provider/model nil keeps the UI badge hidden
// so the user doesn't see a misleading "Powered by …" label.
providerId: nil,
modelId: nil
)
let metadataString: String? = (try? encoder.encode(meta)).flatMap {
String(data: $0, encoding: .utf8)
Expand Down Expand Up @@ -334,20 +388,7 @@ extension StorageManager {
ORDER BY start ASC
""", arguments: [batchId]
).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
}
}
let meta = Self.parseMetadata(row["metadata"], using: decoder)
return TimelineCard(
recordId: row["id"],
batchId: batchId,
Expand All @@ -359,11 +400,13 @@ extension StorageManager {
summary: row["summary"],
detailedSummary: row["detailed_summary"],
day: row["day"],
distractions: distractions,
distractions: meta.distractions,
videoSummaryURL: row["video_summary_url"],
otherVideoSummaryURLs: nil,
appSites: appSites,
isBackupGenerated: isBackupGenerated
appSites: meta.appSites,
isBackupGenerated: meta.isBackupGenerated,
providerId: meta.providerId,
modelId: meta.modelId
)
}
}) ?? []
Expand Down Expand Up @@ -447,20 +490,7 @@ extension StorageManager {
)
.map { row in
// Decode metadata JSON (supports object or legacy array)
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
}
}
let meta = Self.parseMetadata(row["metadata"], using: decoder)

// Create TimelineCard instance using renamed columns
return TimelineCard(
Expand All @@ -474,11 +504,13 @@ extension StorageManager {
summary: row["summary"],
detailedSummary: row["detailed_summary"],
day: row["day"],
distractions: distractions,
distractions: meta.distractions,
videoSummaryURL: row["video_summary_url"],
otherVideoSummaryURLs: nil,
appSites: appSites,
isBackupGenerated: isBackupGenerated
appSites: meta.appSites,
isBackupGenerated: meta.isBackupGenerated,
providerId: meta.providerId,
modelId: meta.modelId
)
}
}
Expand Down Expand Up @@ -508,20 +540,7 @@ extension StorageManager {
)
.map { row in
// Decode metadata JSON (supports object or legacy array)
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
}
}
let meta = Self.parseMetadata(row["metadata"], using: decoder)

// Create TimelineCard instance using renamed columns
return TimelineCard(
Expand All @@ -535,11 +554,13 @@ extension StorageManager {
summary: row["summary"],
detailedSummary: row["detailed_summary"],
day: row["day"],
distractions: distractions,
distractions: meta.distractions,
videoSummaryURL: row["video_summary_url"],
otherVideoSummaryURLs: nil,
appSites: appSites,
isBackupGenerated: isBackupGenerated
appSites: meta.appSites,
isBackupGenerated: meta.isBackupGenerated,
providerId: meta.providerId,
modelId: meta.modelId
)
}
}
Expand Down Expand Up @@ -875,12 +896,16 @@ extension StorageManager {

// Insert new cards
for card in newCards {
// Encode metadata object with distractions and appSites
// Encode metadata object with distractions, appSites, and the
// provider/model info so the UI can render a "powered by" badge
// on each card without re-deriving it.
let meta = TimelineMetadata(
distractions: card.distractions,
appSites: card.appSites,
isBackupGenerated: card.isBackupGenerated,
idle: card.idleMetadata
idle: card.idleMetadata,
providerId: card.providerId,
modelId: card.modelId
)
let metadataString: String? = (try? encoder.encode(meta)).flatMap {
String(data: $0, encoding: .utf8)
Expand Down
Loading