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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,10 @@ model warm-up begins while the user speaks and never blocks the HID-to-Action
path.

Local AI Dictation removes fillers, resolves clear self-corrections, corrects
supported recognition errors, adds punctuation, and chooses paragraphs or
lists when the target safely supports multiline text. It validates protected
supported recognition errors, and applies the selected Natural, Casual
Message, Formal, Technical, or Verbatim Style. It creates validated paragraph
and list blocks, then preserves or flattens structure for the target. It
validates protected
numbers, URLs, email addresses, paths, code-like tokens, quotations, and
dictionary terms. A provider error, invalid output, or three-second deadline
delivers the raw transcript once when the captured target is still safe.
Expand Down
8 changes: 7 additions & 1 deletion Sources/HardwareControllerApp/app_model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ final class AppModel {
localAIReadiness.readiness(for: applicationSnapshot.localAIProvider)
}

var localAIStyle: VoiceStyle {
applicationSnapshot.localAIStyle
}

var localAIProviderTest: LocalAIProviderTestState {
applicationSnapshot.localAIProviderTest
}
Expand Down Expand Up @@ -213,7 +217,9 @@ final class AppModel {
}

var canExecuteLocalAIDictation: Bool {
canExecuteDictation && selectedLocalAIReadiness.state.canRun
canExecuteDictation
&& (localAIStyle.kind == .verbatim
|| selectedLocalAIReadiness.state.canRun)
}

/// Reports whether one configured Action can currently execute.
Expand Down
8 changes: 5 additions & 3 deletions Sources/HardwareControllerApp/application_preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct PreferredMicrophone: Codable, Equatable, Identifiable, Sendable {

/// Stores versioned application presentation preferences.
struct ApplicationPreferences: Codable, Equatable, Sendable {
static let currentSchemaVersion = 4
static let currentSchemaVersion = 5

var appearance: ApplicationAppearance
var sidebarVisibility: SidebarVisibilityPreference
Expand Down Expand Up @@ -289,7 +289,7 @@ struct ApplicationPreferencesStore:
_ preferences: ApplicationPreferences
) throws -> ApplicationPreferences {
switch preferences.schemaVersion {
case 1, 2, 3:
case 1, 2, 3, 4:
var migrated = preferences
migrated.schemaVersion = ApplicationPreferences.currentSchemaVersion
if preferences.schemaVersion == 1 {
Expand All @@ -298,7 +298,9 @@ struct ApplicationPreferencesStore:
if preferences.schemaVersion < 3 {
migrated.localAI = .default
}
migrated.voiceTrigger = .default
if preferences.schemaVersion < 4 {
migrated.voiceTrigger = .default
}
return migrated
case ApplicationPreferences.currentSchemaVersion:
return preferences
Expand Down
10 changes: 7 additions & 3 deletions Sources/HardwareControllerApp/application_runtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ struct ApplicationSnapshot: Equatable, Sendable {
var localAIDictation: LocalAIDictationSnapshot = .idle
var localAIReadiness: LocalAIReadinessSnapshot = .checking
var localAIProvider: LocalAIProviderKind = .appleOnDevice
var localAIStyle: VoiceStyle = .natural
var localAIProviderTest: LocalAIProviderTestState = .idle
var transcriptionPrepared = false
var transcriptionPreparationFailure: TranscriptionFailure?
Expand Down Expand Up @@ -805,6 +806,7 @@ actor ApplicationRuntime {
systemState.speechRecognitionPermission,
transcription: transcription,
localAIProvider: localAISettings.provider,
localAIStyle: localAISettings.style,
launchAtLogin: systemState.launchAtLogin,
recoveryNotice: nil
)
Expand Down Expand Up @@ -1468,6 +1470,7 @@ actor ApplicationRuntime {
localAIProviderTestGeneration &+= 1
localAISettings = settings
snapshot.localAIProvider = settings.provider
snapshot.localAIStyle = settings.style
snapshot.localAIProviderTest = .idle
snapshot.localAIReadiness = .checking
updateRuntimeAvailability()
Expand Down Expand Up @@ -1785,9 +1788,10 @@ actor ApplicationRuntime {
/// Reports whether permissions and the selected local provider are ready.
private var canExecuteLocalAIDictation: Bool {
canExecuteDictation
&& snapshot.localAIReadiness.readiness(
for: localAISettings.provider
).state.canRun
&& (localAISettings.style.kind == .verbatim
|| snapshot.localAIReadiness.readiness(
for: localAISettings.provider
).state.canRun)
}

/// Reports whether synthetic shortcuts can currently execute.
Expand Down
5 changes: 4 additions & 1 deletion Sources/HardwareControllerApp/controller_view.swift
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,10 @@ private struct LocalAITranscriptionStatusView: View {
}

private var readinessDetail: String {
switch model.selectedLocalAIReadiness.state {
if model.localAIStyle.kind == .verbatim {
return "Ready. Verbatim skips generative formatting."
}
return switch model.selectedLocalAIReadiness.state {
case .checking:
"Checking the selected local provider…"
case .ready:
Expand Down
63 changes: 59 additions & 4 deletions Sources/HardwareControllerApp/local_ai_settings_view.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ struct LocalAISettingsSection: View {
}
.pickerStyle(.segmented)

Picker("Style", selection: styleBinding) {
ForEach(VoiceStyleKind.allCases, id: \.self) { kind in
Text(styleTitle(kind)).tag(kind)
}
}
Text(styleDescription(settings.style.kind))
.font(.caption)
.foregroundStyle(.secondary)

if settings.provider == .ollama {
Picker("Model", selection: modelBinding) {
ForEach(modelOptions) { option in
Expand Down Expand Up @@ -103,7 +112,7 @@ struct LocalAISettingsSection: View {
.stroke(.quaternary)
}
Text(
"Optional style guidance. Core accuracy, privacy, and prompt-safety rules always remain active."
"Optional workflow guidance. Accuracy, privacy, and prompt-safety rules always remain active."
)
.font(.caption)
.foregroundStyle(.secondary)
Expand Down Expand Up @@ -242,6 +251,15 @@ struct LocalAISettingsSection: View {
)
}

private var styleBinding: SwiftUI.Binding<VoiceStyleKind> {
SwiftUI.Binding(
get: { settings.style.kind },
set: { kind in
updateSettings { $0.style = VoiceStyle(kind: kind) }
}
)
}

private var modelBinding: SwiftUI.Binding<String> {
SwiftUI.Binding(
get: { settings.ollamaModel.name },
Expand Down Expand Up @@ -290,7 +308,10 @@ struct LocalAISettingsSection: View {
}

private var readinessSymbol: String {
switch selectedReadiness.state {
if settings.style.kind == .verbatim {
return "checkmark.circle.fill"
}
return switch selectedReadiness.state {
case .ready:
"checkmark.circle.fill"
case .checking:
Expand All @@ -301,11 +322,15 @@ struct LocalAISettingsSection: View {
}

private var readinessColor: Color {
selectedReadiness.state.canRun ? .secondary : StudioDesign.warning
settings.style.kind == .verbatim || selectedReadiness.state.canRun
? .secondary : StudioDesign.warning
}

private var readinessDetail: String {
switch selectedReadiness.state {
if settings.style.kind == .verbatim {
return "Not used by Verbatim Style"
}
return switch selectedReadiness.state {
case .checking:
"Checking…"
case .ready:
Expand Down Expand Up @@ -357,6 +382,36 @@ struct LocalAISettingsSection: View {
: "\(model.name) — \(suffixes.joined(separator: ", "))"
}

private func styleTitle(_ kind: VoiceStyleKind) -> String {
switch kind {
case .natural:
"Natural"
case .casualMessage:
"Casual Message"
case .formal:
"Formal"
case .technical:
"Technical"
case .verbatim:
"Verbatim"
}
}

private func styleDescription(_ kind: VoiceStyleKind) -> String {
switch kind {
case .natural:
"Clear everyday writing that retains your voice."
case .casualMessage:
"Concise, lowercase conversational text for chats and messages."
case .formal:
"Professional grammar and complete sentences."
case .technical:
"Concise structure with commands and code preserved exactly."
case .verbatim:
"Recognition output with no generative rewriting."
}
}

private var normalizedVocabularyEntry: String {
vocabularyEntry.trimmingCharacters(in: .whitespacesAndNewlines)
}
Expand Down
57 changes: 55 additions & 2 deletions Sources/HardwareControllerCore/local_ai_dictation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
public var includeNearbyText: Bool
public var dictionary: PersonalDictionary
public var additionalInstructions: String
public var style: VoiceStyle

public init(
provider: LocalAIProviderKind = .appleOnDevice,
Expand All @@ -74,14 +75,57 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
modelRetention: LocalAIModelRetention = .recentUse,
includeNearbyText: Bool = false,
dictionary: PersonalDictionary = .empty,
additionalInstructions: String = ""
additionalInstructions: String = "",
style: VoiceStyle = .natural
) {
self.provider = provider
self.ollamaModel = ollamaModel
self.modelRetention = modelRetention
self.includeNearbyText = includeNearbyText
self.dictionary = dictionary
self.additionalInstructions = additionalInstructions
self.style = style
}

private enum CodingKeys: String, CodingKey {
case provider
case ollamaModel
case modelRetention
case includeNearbyText
case dictionary
case additionalInstructions
case style
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
provider = try container.decode(LocalAIProviderKind.self, forKey: .provider)
ollamaModel = try container.decode(
LocalAIModelSelection.self,
forKey: .ollamaModel
)
modelRetention = try container.decode(
LocalAIModelRetention.self,
forKey: .modelRetention
)
includeNearbyText = try container.decode(Bool.self, forKey: .includeNearbyText)
dictionary = try container.decode(PersonalDictionary.self, forKey: .dictionary)
additionalInstructions = try container.decode(
String.self,
forKey: .additionalInstructions
)
style = try container.decodeIfPresent(VoiceStyle.self, forKey: .style) ?? .natural
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(provider, forKey: .provider)
try container.encode(ollamaModel, forKey: .ollamaModel)
try container.encode(modelRetention, forKey: .modelRetention)
try container.encode(includeNearbyText, forKey: .includeNearbyText)
try container.encode(dictionary, forKey: .dictionary)
try container.encode(additionalInstructions, forKey: .additionalInstructions)
try container.encode(style, forKey: .style)
}

public static let `default` = LocalAISettings()
Expand All @@ -97,10 +141,16 @@ public enum LocalAISettingsValidationError: Error, Equatable, Sendable {
case invalidReplacement
case duplicateSpokenForm
case instructionsTooLong
case unsupportedStyleRevision(Int)
}

extension LocalAISettings {
public func validate() throws {
guard style.revision == VoiceStyle.currentRevision else {
throw LocalAISettingsValidationError.unsupportedStyleRevision(
style.revision
)
}
guard !ollamaModel.name.normalizedLocalAIValue.isEmpty else {
throw LocalAISettingsValidationError.emptyModelName
}
Expand Down Expand Up @@ -181,19 +231,22 @@ public struct LocalAIRefinementRequest: Equatable, Sendable {
public let context: LocalAITargetContext
public let dictionary: PersonalDictionary
public let additionalInstructions: String
public let style: VoiceStyle

public init(
sessionID: UUID,
transcript: String,
context: LocalAITargetContext,
dictionary: PersonalDictionary,
additionalInstructions: String
additionalInstructions: String,
style: VoiceStyle = .natural
) {
self.sessionID = sessionID
self.transcript = transcript
self.context = context
self.dictionary = dictionary
self.additionalInstructions = additionalInstructions
self.style = style
}
}

Expand Down
Loading