Skip to content

Commit 1eb28ca

Browse files
authored
Merge pull request #322 from altic-dev/B/321-prompt-processing-ui
Fix AI cleanup prompt controls
2 parents b7ec141 + b9ace56 commit 1eb28ca

7 files changed

Lines changed: 501 additions & 180 deletions

Sources/Fluid/Services/DictationAIPostProcessingGate.swift

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import CryptoKit
12
import Foundation
23

34
/// Shared gating logic for whether dictation AI post-processing is usable/configured.
45
enum DictationAIPostProcessingGate {
56
/// Returns true if dictation AI post-processing should be allowed, given current settings.
67
/// - Requires dictation prompt selection to not be `Off`
7-
/// - For Apple Intelligence: requires `AppleIntelligenceService.isAvailable`
8-
/// - For other providers: requires a local endpoint OR a non-empty API key
8+
/// - Requires the selected provider connection to still be verified
99
static func isConfigured() -> Bool {
1010
self.isConfigured(for: .primary)
1111
}
@@ -17,18 +17,23 @@ enum DictationAIPostProcessingGate {
1717
return self.isProviderConfigured()
1818
}
1919

20-
/// Returns true if the selected AI provider is reachable/configured (API key or local endpoint),
20+
/// Returns true if the selected AI provider is currently verified/configured,
2121
/// regardless of the AI toggle or prompt selection. Used to gate prompt-mode hotkey AI processing.
2222
static func isProviderConfigured() -> Bool {
2323
let settings = SettingsStore.shared
2424
let providerID = settings.selectedProviderID
25+
let key = self.providerKey(for: providerID)
26+
guard let storedFingerprint = settings.verifiedProviderFingerprints[key] else { return false }
27+
2528
if providerID == "apple-intelligence" {
26-
return AppleIntelligenceService.isAvailable
29+
return storedFingerprint == "apple-intelligence" && AppleIntelligenceService.isAvailable
2730
}
31+
2832
let baseURL = self.baseURL(for: providerID, settings: settings)
29-
if self.isLocalEndpoint(baseURL) { return true }
3033
let apiKey = (settings.getAPIKey(for: providerID) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
31-
return !apiKey.isEmpty
34+
guard self.isLocalEndpoint(baseURL) || !apiKey.isEmpty else { return false }
35+
36+
return self.providerFingerprint(baseURL: baseURL, apiKey: apiKey) == storedFingerprint
3237
}
3338

3439
static func baseURL(for providerID: String, settings: SettingsStore) -> String {
@@ -43,6 +48,22 @@ enum DictationAIPostProcessingGate {
4348
return ModelRepository.shared.defaultBaseURL(for: "openai")
4449
}
4550

51+
static func providerKey(for providerID: String) -> String {
52+
if ModelRepository.shared.isBuiltIn(providerID) { return providerID }
53+
if providerID.hasPrefix("custom:") { return providerID }
54+
return "custom:\(providerID)"
55+
}
56+
57+
static func providerFingerprint(baseURL: String, apiKey: String) -> String? {
58+
let trimmedBase = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
59+
let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
60+
guard !trimmedBase.isEmpty else { return nil }
61+
62+
let input = "\(trimmedBase)|\(trimmedKey)"
63+
let digest = SHA256.hash(data: Data(input.utf8))
64+
return digest.map { String(format: "%02x", $0) }.joined()
65+
}
66+
4667
static func isLocalEndpoint(_ urlString: String) -> Bool {
4768
guard let url = URL(string: urlString), let host = url.host else { return false }
4869
let hostLower = host.lowercased()

Sources/Fluid/Theme/NativeButtonStyles.swift

Lines changed: 86 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,73 @@
11
import SwiftUI
22

3+
enum FluidInteractionVisuals {
4+
static let hoverScale: CGFloat = 1.01
5+
static let pressedScale: CGFloat = 0.97
6+
static let hoverAnimation: Animation = .spring(response: 0.18, dampingFraction: 0.78)
7+
static let pressedAnimation: Animation = .spring(response: 0.2, dampingFraction: 0.8)
8+
9+
static func scale(isPressed: Bool, isHovered: Bool) -> CGFloat {
10+
if isPressed { return self.pressedScale }
11+
return isHovered ? self.hoverScale : 1
12+
}
13+
}
14+
15+
extension View {
16+
func fluidControlSurface(
17+
isSelected: Bool,
18+
isHovered: Bool,
19+
tone: Color,
20+
cornerRadius: CGFloat
21+
) -> some View {
22+
self.modifier(FluidControlSurfaceModifier(
23+
isSelected: isSelected,
24+
isHovered: isHovered,
25+
tone: tone,
26+
cornerRadius: cornerRadius
27+
))
28+
}
29+
}
30+
31+
private struct FluidControlSurfaceModifier: ViewModifier {
32+
@Environment(\.theme) private var theme
33+
let isSelected: Bool
34+
let isHovered: Bool
35+
let tone: Color
36+
let cornerRadius: CGFloat
37+
38+
func body(content: Content) -> some View {
39+
let shape = RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
40+
let fillOpacity = self.isSelected ? 0.96 : (self.isHovered ? 0.42 : 0)
41+
let shineOpacity = self.isSelected ? 0.14 : (self.isHovered ? 0.07 : 0)
42+
let strokeColor = self.isSelected
43+
? self.tone.opacity(0.24)
44+
: (self.isHovered ? self.theme.palette.cardBorder.opacity(0.28) : .clear)
45+
46+
content
47+
.background(
48+
shape
49+
.fill(self.theme.palette.cardBackground.opacity(fillOpacity))
50+
.overlay(
51+
LinearGradient(
52+
colors: [.white.opacity(shineOpacity), .clear],
53+
startPoint: .topLeading,
54+
endPoint: .bottomTrailing
55+
)
56+
.clipShape(shape)
57+
)
58+
.overlay(shape.stroke(strokeColor, lineWidth: 1))
59+
.shadow(
60+
color: .black.opacity(self.isSelected ? 0.16 : (self.isHovered ? 0.08 : 0)),
61+
radius: self.isSelected || self.isHovered ? 5 : 0,
62+
y: self.isSelected || self.isHovered ? 1 : 0
63+
)
64+
)
65+
.scaleEffect(self.isHovered && !self.isSelected ? FluidInteractionVisuals.hoverScale : 1)
66+
.animation(FluidInteractionVisuals.hoverAnimation, value: self.isSelected)
67+
.animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered)
68+
}
69+
}
70+
371
// MARK: - Primary (Prominent) Button
472

573
struct GlassButtonStyle: ButtonStyle {
@@ -48,9 +116,9 @@ struct GlassButtonStyle: ButtonStyle {
48116
x: 0,
49117
y: self.isHovered ? self.theme.metrics.cardShadow.y : self.theme.metrics.cardShadow.y - 2
50118
)
51-
.scaleEffect(self.configuration.isPressed ? 0.97 : (self.isHovered ? 1.01 : 1.0))
52-
.animation(.spring(response: 0.18, dampingFraction: 0.78), value: self.isHovered)
53-
.animation(.spring(response: 0.2, dampingFraction: 0.8), value: self.configuration.isPressed)
119+
.scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered))
120+
.animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered)
121+
.animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed)
54122
.onHover { self.isHovered = $0 }
55123
}
56124
}
@@ -122,9 +190,9 @@ struct PremiumButtonStyle: ButtonStyle {
122190
x: 0,
123191
y: self.isHovered ? self.theme.metrics.elevatedCardShadow.y : self.theme.metrics.cardShadow.y
124192
)
125-
.scaleEffect(self.configuration.isPressed ? 0.98 : (self.isHovered ? 1.01 : 1.0))
126-
.animation(.spring(response: 0.18, dampingFraction: 0.75), value: self.isHovered)
127-
.animation(.spring(response: 0.18, dampingFraction: 0.75), value: self.configuration.isPressed)
193+
.scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered))
194+
.animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered)
195+
.animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed)
128196
.onHover { self.isHovered = $0 }
129197
}
130198
}
@@ -169,9 +237,9 @@ struct SecondaryButtonStyle: ButtonStyle {
169237
x: 0,
170238
y: self.isHovered ? self.theme.metrics.cardShadow.y : self.theme.metrics.cardShadow.y - 2
171239
)
172-
.scaleEffect(self.configuration.isPressed ? 0.98 : (self.isHovered ? 1.01 : 1.0))
173-
.animation(.spring(response: 0.18, dampingFraction: 0.78), value: self.isHovered)
174-
.animation(.spring(response: 0.2, dampingFraction: 0.8), value: self.configuration.isPressed)
240+
.scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered))
241+
.animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered)
242+
.animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed)
175243
.onHover { self.isHovered = $0 }
176244
}
177245
}
@@ -231,9 +299,9 @@ struct CompactButtonStyle: ButtonStyle {
231299
x: 0,
232300
y: self.isHovered ? self.theme.metrics.cardShadow.y - 1 : 1
233301
)
234-
.scaleEffect(self.configuration.isPressed ? 0.97 : (self.isHovered ? 1.01 : 1.0))
235-
.animation(.spring(response: 0.18, dampingFraction: 0.78), value: self.isHovered)
236-
.animation(.spring(response: 0.2, dampingFraction: 0.8), value: self.configuration.isPressed)
302+
.scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered))
303+
.animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered)
304+
.animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed)
237305
.onHover { self.isHovered = $0 }
238306
}
239307
}
@@ -288,9 +356,9 @@ struct AccentButtonStyle: ButtonStyle {
288356
x: 0,
289357
y: self.isHovered ? 3 : 2
290358
)
291-
.scaleEffect(self.configuration.isPressed ? 0.97 : (self.isHovered ? 1.02 : 1.0))
292-
.animation(.spring(response: 0.18, dampingFraction: 0.78), value: self.isHovered)
293-
.animation(.spring(response: 0.2, dampingFraction: 0.8), value: self.configuration.isPressed)
359+
.scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered))
360+
.animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered)
361+
.animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed)
294362
.onHover { self.isHovered = $0 }
295363
}
296364
}
@@ -329,9 +397,9 @@ struct InlineButtonStyle: ButtonStyle {
329397
x: 0,
330398
y: self.isHovered ? 3 : 1
331399
)
332-
.scaleEffect(self.configuration.isPressed ? 0.96 : (self.isHovered ? 1.03 : 1.0))
333-
.animation(.easeOut(duration: 0.15), value: self.isHovered)
334-
.animation(.easeOut(duration: 0.15), value: self.configuration.isPressed)
400+
.scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered))
401+
.animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered)
402+
.animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed)
335403
.onHover { self.isHovered = $0 }
336404
}
337405
}

Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ struct AIEnhancementSettingsView: View {
1111
@State var fluid1InterestErrorMessage: String = ""
1212
@State var fluid1InterestIsSubmitting: Bool = false
1313
@State var hoveredPromptCardKey: String? = nil
14+
@State var selectedPromptMode: SettingsStore.PromptMode = .dictate
15+
@State var hoveredPromptModeKey: String? = nil
16+
@State var hoveredCleanupControlKey: String? = nil
1417

1518
var body: some View {
1619
self.aiConfigurationCard

Sources/Fluid/UI/AISettingsView+AIConfiguration.swift

Lines changed: 81 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -44,48 +44,14 @@ extension AIEnhancementSettingsView {
4444
.cornerRadius(6)
4545
}
4646

47-
func aiOnboardingInfoRow(_ body: String) -> some View {
48-
HStack(alignment: .top, spacing: 8) {
49-
Circle()
50-
.fill(self.theme.palette.accent.opacity(0.9))
51-
.frame(width: 5, height: 5)
52-
.padding(.top, 5)
53-
Text(body)
54-
.font(.caption)
55-
.foregroundStyle(.secondary)
56-
}
57-
}
58-
5947
// MARK: - AI Configuration Card
6048

6149
var aiConfigurationCard: some View {
6250
VStack(spacing: 14) {
6351
ThemedCard(style: .prominent, hoverEffect: false) {
6452
VStack(alignment: .leading, spacing: 16) {
65-
HStack(spacing: 12) {
66-
HStack(spacing: 10) {
67-
Image(systemName: "brain")
68-
.font(.title3)
69-
.foregroundStyle(self.theme.palette.accent)
70-
Text("AI Setup")
71-
.font(.title3)
72-
.fontWeight(.semibold)
73-
}
74-
Spacer()
75-
}
76-
77-
Divider()
78-
.background(self.theme.palette.separator.opacity(0.5))
79-
80-
VStack(alignment: .leading, spacing: 8) {
81-
Text("Choose a provider, model, and dictation prompt. Select `Off` in Dictate prompts for raw transcription.")
82-
.font(.caption)
83-
.foregroundStyle(.secondary)
84-
85-
self.aiOnboardingInfoRow("Local models run on your Mac. Examples: Ollama and LM Studio.")
86-
self.aiOnboardingInfoRow("Cloud models use a provider of your choice. Examples: OpenAI, Anthropic, Groq, and OpenRouter.")
87-
self.aiOnboardingInfoRow("Dictation uses AI when Dictate prompt is `Default` or a custom prompt.")
88-
}
53+
self.aiSetupHeader
54+
self.aiSetupSummaryBar
8955

9056
HStack(spacing: 12) {
9157
VStack(alignment: .leading, spacing: 2) {
@@ -135,6 +101,85 @@ extension AIEnhancementSettingsView {
135101
}
136102
}
137103

104+
private var aiSetupHeader: some View {
105+
HStack(spacing: 12) {
106+
ZStack {
107+
RoundedRectangle(cornerRadius: 10, style: .continuous)
108+
.fill(self.theme.palette.contentBackground.opacity(0.82))
109+
.overlay(
110+
LinearGradient(
111+
colors: [.white.opacity(0.1), .clear],
112+
startPoint: .topLeading,
113+
endPoint: .bottomTrailing
114+
)
115+
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
116+
)
117+
.overlay(
118+
RoundedRectangle(cornerRadius: 10, style: .continuous)
119+
.stroke(self.theme.palette.accent.opacity(0.35), lineWidth: 1)
120+
)
121+
122+
Image(systemName: "brain")
123+
.font(.system(size: 15, weight: .semibold))
124+
.foregroundStyle(self.theme.palette.accent)
125+
}
126+
.frame(width: 34, height: 34)
127+
128+
VStack(alignment: .leading, spacing: 2) {
129+
Text("AI Enhancements")
130+
.font(.title3)
131+
.fontWeight(.semibold)
132+
.foregroundStyle(self.theme.palette.primaryText)
133+
Text("Choose the model used for AI Cleanup.")
134+
.font(.caption)
135+
.foregroundStyle(self.theme.palette.secondaryText)
136+
}
137+
138+
Spacer()
139+
}
140+
}
141+
142+
private var aiSetupSummaryBar: some View {
143+
ViewThatFits(in: .horizontal) {
144+
HStack(spacing: 12) {
145+
self.aiSetupSummaryItem(icon: "cpu", text: "Local models run on Mac")
146+
self.aiSetupSummaryDivider
147+
self.aiSetupSummaryItem(icon: "cloud", text: "Cloud models use provider APIs")
148+
self.aiSetupSummaryDivider
149+
self.aiSetupSummaryItem(icon: "slider.horizontal.3", text: "AI Cleanup enables dictation prompts")
150+
}
151+
152+
VStack(alignment: .leading, spacing: 7) {
153+
self.aiSetupSummaryItem(icon: "cpu", text: "Local models run on Mac")
154+
self.aiSetupSummaryItem(icon: "cloud", text: "Cloud models use provider APIs")
155+
self.aiSetupSummaryItem(icon: "slider.horizontal.3", text: "AI Cleanup enables dictation prompts")
156+
}
157+
}
158+
.padding(.horizontal, 2)
159+
.padding(.vertical, 2)
160+
.frame(maxWidth: .infinity, alignment: .leading)
161+
}
162+
163+
private var aiSetupSummaryDivider: some View {
164+
Rectangle()
165+
.fill(self.theme.palette.separator.opacity(0.45))
166+
.frame(width: 1, height: 14)
167+
}
168+
169+
private func aiSetupSummaryItem(icon: String, text: String) -> some View {
170+
HStack(spacing: 6) {
171+
Image(systemName: icon)
172+
.font(.system(size: 11, weight: .semibold))
173+
.foregroundStyle(self.theme.palette.accent.opacity(0.95))
174+
.frame(width: 14)
175+
176+
Text(text)
177+
.font(.system(size: 12, weight: .medium))
178+
.foregroundStyle(self.theme.palette.secondaryText)
179+
.lineLimit(1)
180+
}
181+
}
182+
138183
var apiKeyWarningView: some View {
139184
HStack(spacing: 10) {
140185
Image(systemName: "exclamationmark.triangle.fill")

0 commit comments

Comments
 (0)