@@ -12,6 +12,10 @@ import FoundationModels
1212#endif
1313
1414struct PlanetSettingsAIView : View {
15+ private enum Layout {
16+ static let secondaryRowLeadingInset : CGFloat = 8
17+ }
18+
1519 @State private var aiAPIBase : String = UserDefaults . standard. string ( forKey: . settingsAIAPIBase) ?? " "
1620 @State private var aiAPIToken : String = " "
1721 @State private var isShowingToken : Bool = false
@@ -84,140 +88,183 @@ struct PlanetSettingsAIView: View {
8488 var body : some View {
8589 Form {
8690 Section {
87- TextField ( " API Base URL " , text: $aiAPIBase)
88- . textFieldStyle ( . roundedBorder)
89- . onChange ( of: aiAPIBase) { newValue in
90- UserDefaults . standard. set ( newValue, forKey: . settingsAIAPIBase)
91- scheduleCheck ( )
92- }
93- if hasInsecureHTTPError {
94- Text ( AIEndpointSecurityPolicy . insecureHTTPErrorDescription)
95- . font ( . footnote)
96- . foregroundStyle ( . red)
97- . padding ( . top, 1 )
98- . padding ( . bottom, 8 )
99- }
91+ PlanetSettingsContainer {
92+ VStack ( alignment: . leading, spacing: PlanetSettingsSharedLayout . descriptionSpacing) {
93+ PlanetSettingsRow ( " API Base URL " ) {
94+ TextField ( " " , text: $aiAPIBase)
95+ . textFieldStyle ( . roundedBorder)
96+ . accessibilityLabel ( " API Base URL " )
97+ . onChange ( of: aiAPIBase) { newValue in
98+ UserDefaults . standard. set ( newValue, forKey: . settingsAIAPIBase)
99+ scheduleCheck ( )
100+ }
101+ }
100102
101- if ollamaDetected {
102- HStack ( spacing: 8 ) {
103- StatusIndicatorView ( state: . success)
104- if isUsingOllama {
105- Text ( " Using Ollama " )
106- } else {
107- Text ( " Ollama detected on localhost " )
108- Spacer ( )
109- Button ( " Use Ollama " ) {
110- aiAPIBase = " http://localhost:11434/v1 "
103+ if hasInsecureHTTPError {
104+ PlanetSettingsControlRow ( alignment: . top) {
105+ Text ( AIEndpointSecurityPolicy . insecureHTTPErrorDescription)
106+ . font ( . footnote)
107+ . foregroundStyle ( . red)
108+ . fixedSize ( horizontal: false , vertical: true )
111109 }
112110 }
113- }
114- . padding ( . bottom, 4 )
115- }
116111
117- if lmStudioDetected {
118- HStack ( spacing: 8 ) {
119- StatusIndicatorView ( state: . success)
120- if isUsingLMStudio {
121- Text ( " Using LM Studio " )
122- } else {
123- Text ( " LM Studio detected on localhost " )
124- Spacer ( )
125- Button ( " Use LM Studio " ) {
126- aiAPIBase = " http://localhost:1234/v1 "
112+ if ollamaDetected {
113+ PlanetSettingsControlRow {
114+ HStack ( spacing: 8 ) {
115+ StatusIndicatorView ( state: . success)
116+ if isUsingOllama {
117+ Text ( " Using Ollama " )
118+ . fixedSize ( horizontal: false , vertical: true )
119+ } else {
120+ Text ( " Ollama detected on localhost " )
121+ . fixedSize ( horizontal: false , vertical: true )
122+ Spacer ( )
123+ Button ( " Use Ollama " ) {
124+ aiAPIBase = " http://localhost:11434/v1 "
125+ }
126+ }
127+ }
128+ . frame ( maxWidth: . infinity, alignment: . leading)
129+ . padding ( . leading, Layout . secondaryRowLeadingInset)
127130 }
128131 }
129- }
130- . padding ( . bottom, 4 )
131- }
132- }
133132
134- Section {
135- ZStack {
136- TextField ( " API Token " , text: $aiAPIToken)
137- . opacity ( isShowingToken ? 1.0 : 0.0 )
138- SecureField ( " API Token " , text: $aiAPIToken)
139- . opacity ( !isShowingToken ? 1.0 : 0.0 )
140- HStack {
141- Spacer ( )
142- Button {
143- isShowingToken. toggle ( )
144- } label: {
145- Image ( systemName: !isShowingToken ? " eye.slash " : " eye " )
146- . resizable ( )
147- . aspectRatio ( contentMode: . fit)
148- . frame ( width: 14 , height: 14 , alignment: . center)
133+ if lmStudioDetected {
134+ PlanetSettingsControlRow {
135+ HStack ( spacing: 8 ) {
136+ StatusIndicatorView ( state: . success)
137+ if isUsingLMStudio {
138+ Text ( " Using LM Studio " )
139+ . fixedSize ( horizontal: false , vertical: true )
140+ } else {
141+ Text ( " LM Studio detected on localhost " )
142+ . fixedSize ( horizontal: false , vertical: true )
143+ Spacer ( )
144+ Button ( " Use LM Studio " ) {
145+ aiAPIBase = " http://localhost:1234/v1 "
146+ }
147+ }
148+ }
149+ . frame ( maxWidth: . infinity, alignment: . leading)
150+ . padding ( . leading, Layout . secondaryRowLeadingInset)
151+ }
149152 }
150- . buttonStyle ( . plain)
151153 }
152- . padding ( . horizontal, 8 )
153- }
154- . textFieldStyle ( . roundedBorder)
155- . onChange ( of: aiAPIToken) { newValue in
156- Task { @MainActor in
157- do {
158- if newValue. isEmpty {
159- try KeychainHelper . shared. delete ( forKey: . settingsAIAPIToken)
160- } else {
161- try KeychainHelper . shared. saveValue ( newValue, forKey: . settingsAIAPIToken)
154+
155+ PlanetSettingsRow ( " API Token " ) {
156+ ZStack {
157+ TextField ( " " , text: $aiAPIToken)
158+ . opacity ( isShowingToken ? 1.0 : 0.0 )
159+ . accessibilityHidden ( !isShowingToken)
160+ SecureField ( " " , text: $aiAPIToken)
161+ . opacity ( !isShowingToken ? 1.0 : 0.0 )
162+ . accessibilityHidden ( isShowingToken)
163+ HStack {
164+ Spacer ( )
165+ Button {
166+ isShowingToken. toggle ( )
167+ } label: {
168+ Label (
169+ isShowingToken ? " Hide API Token " : " Show API Token " ,
170+ systemImage: !isShowingToken ? " eye.slash " : " eye "
171+ )
172+ . labelStyle ( . iconOnly)
173+ . font ( . system( size: 14 ) )
174+ . frame ( width: 14 , height: 14 , alignment: . center)
175+ }
176+ . buttonStyle ( . plain)
177+ }
178+ . padding ( . horizontal, 8 )
179+ }
180+ . textFieldStyle ( . roundedBorder)
181+ . accessibilityLabel ( " API Token " )
182+ . onChange ( of: aiAPIToken) { newValue in
183+ Task { @MainActor in
184+ do {
185+ if newValue. isEmpty {
186+ try KeychainHelper . shared. delete ( forKey: . settingsAIAPIToken)
187+ } else {
188+ try KeychainHelper . shared. saveValue ( newValue, forKey: . settingsAIAPIToken)
189+ }
190+ } catch {
191+ debugPrint ( " failed to save AI API token: \( error) " )
192+ }
162193 }
163- } catch {
164- debugPrint ( " failed to save AI API token: \( error) " )
194+ scheduleCheck ( )
165195 }
166196 }
167- scheduleCheck ( )
168- }
169- }
170197
171- Section {
172- TextField ( " Preferred Model " , text: $aiPreferredModel)
173- . textFieldStyle ( . roundedBorder)
174- . focused ( $isModelFieldFocused)
175- . onChange ( of: aiPreferredModel) { newValue in
176- UserDefaults . standard. set ( newValue, forKey: . settingsAIPreferredModel)
177- schedulePreferredModelCheck ( )
178- }
198+ VStack ( alignment: . leading, spacing: PlanetSettingsSharedLayout . descriptionSpacing) {
199+ PlanetSettingsRow ( " Preferred Model " ) {
200+ TextField ( " " , text: $aiPreferredModel)
201+ . textFieldStyle ( . roundedBorder)
202+ . accessibilityLabel ( " Preferred Model " )
203+ . focused ( $isModelFieldFocused)
204+ . onChange ( of: aiPreferredModel) { newValue in
205+ UserDefaults . standard. set ( newValue, forKey: . settingsAIPreferredModel)
206+ schedulePreferredModelCheck ( )
207+ }
208+ }
179209
180- if showSuggestions {
181- ScrollView {
182- VStack ( alignment: . leading, spacing: 0 ) {
183- ForEach ( filteredModelIDs, id: \. self) { modelID in
184- Text ( modelID)
185- . font ( . system( . body, design: . monospaced) )
186- . padding ( . horizontal, 4 )
187- . padding ( . vertical, 5 )
188- . frame ( maxWidth: . infinity, alignment: . leading)
189- . contentShape ( Rectangle ( ) )
190- . onTapGesture {
191- aiPreferredModel = modelID
192- isModelFieldFocused = false
210+ if showSuggestions {
211+ PlanetSettingsControlRow ( alignment: . top) {
212+ ScrollView {
213+ VStack ( alignment: . leading, spacing: 0 ) {
214+ ForEach ( filteredModelIDs, id: \. self) { modelID in
215+ Button {
216+ aiPreferredModel = modelID
217+ isModelFieldFocused = false
218+ } label: {
219+ Text ( modelID)
220+ . font ( . system( . body, design: . monospaced) )
221+ . padding ( . horizontal, 4 )
222+ . padding ( . vertical, 5 )
223+ . frame ( maxWidth: . infinity, alignment: . leading)
224+ . contentShape ( Rectangle ( ) )
225+ }
226+ . buttonStyle ( . plain)
227+ if modelID != filteredModelIDs. last {
228+ Divider ( )
229+ }
230+ }
193231 }
194- if modelID != filteredModelIDs. last {
195- Divider ( )
196232 }
233+ . frame ( maxHeight: 160 )
234+ . background ( Color ( NSColor . controlBackgroundColor) )
235+ . cornerRadius ( 6 )
236+ . overlay (
237+ RoundedRectangle ( cornerRadius: 6 )
238+ . stroke ( Color . secondary. opacity ( 0.2 ) , lineWidth: 1 )
239+ )
240+ . padding ( . leading, Layout . secondaryRowLeadingInset)
197241 }
198242 }
199243 }
200- . frame ( maxHeight: 160 )
201- . background ( Color ( NSColor . controlBackgroundColor) )
202- . cornerRadius ( 6 )
203- . overlay ( RoundedRectangle ( cornerRadius: 6 ) . stroke ( Color . secondary. opacity ( 0.2 ) , lineWidth: 1 ) )
204- }
205- }
206244
207- Section {
208- HStack ( spacing: 8 ) {
209- StatusIndicatorView ( state: statusIndicatorState)
210- statusLabel
211- }
212- . padding ( . top, 4 )
213- }
245+ VStack ( alignment: . leading, spacing: PlanetSettingsSharedLayout . descriptionSpacing) {
246+ PlanetSettingsControlRow ( alignment: . top) {
247+ HStack ( spacing: 8 ) {
248+ StatusIndicatorView ( state: statusIndicatorState)
249+ statusLabel
250+ . frame ( maxWidth: . infinity, alignment: . leading)
251+ . fixedSize ( horizontal: false , vertical: true )
252+ }
253+ . padding ( . leading, Layout . secondaryRowLeadingInset)
254+ }
214255
215- Section {
216- HStack ( spacing: 8 ) {
217- StatusIndicatorView ( state: onDeviceAIState)
218- onDeviceAIStatusLabel
256+ PlanetSettingsControlRow ( alignment: . top) {
257+ HStack ( alignment: . top, spacing: 8 ) {
258+ StatusIndicatorView ( state: onDeviceAIState)
259+ onDeviceAIStatusLabel
260+ . frame ( maxWidth: . infinity, alignment: . leading)
261+ . fixedSize ( horizontal: false , vertical: true )
262+ }
263+ . padding ( . leading, Layout . secondaryRowLeadingInset)
264+ }
265+ }
266+ . padding ( . top, 4 )
219267 }
220- . padding ( . top, 4 )
221268 }
222269
223270 Spacer ( )
0 commit comments