Skip to content

Commit fb9f00a

Browse files
committed
AI settings and the initial chat view
1 parent 8d3a60d commit fb9f00a

7 files changed

Lines changed: 719 additions & 1 deletion

File tree

Planet.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,8 @@
434434
6A8A6A592BA5B0CA00FBF67D /* MyArticleModel+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A6A582BA5B0CA00FBF67D /* MyArticleModel+Views.swift */; };
435435
6A8A6A5A2BA5B0CA00FBF67D /* MyArticleModel+Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A6A582BA5B0CA00FBF67D /* MyArticleModel+Views.swift */; };
436436
6A8C3A6728C9361800339A86 /* DotBitKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8C3A6628C9361800339A86 /* DotBitKit.swift */; };
437+
6A8D7DCC2F49C14B0084703B /* PlanetSettingsAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8D7DCB2F49C1420084703B /* PlanetSettingsAIView.swift */; };
438+
6A8D7DCD2F49C14B0084703B /* PlanetSettingsAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8D7DCB2F49C1420084703B /* PlanetSettingsAIView.swift */; };
437439
6A900193296C9F3800BC088E /* MyArticleSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A900192296C9F3800BC088E /* MyArticleSettingsView.swift */; };
438440
6A941CED2AF5E7A300CA8261 /* PlanetStore+ServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A941CEC2AF5E7A300CA8261 /* PlanetStore+ServerInfo.swift */; };
439441
6A941CEE2AF5E7A300CA8261 /* PlanetStore+ServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A941CEC2AF5E7A300CA8261 /* PlanetStore+ServerInfo.swift */; };
@@ -796,6 +798,7 @@
796798
6A8776D32A01B78800C6003B /* Pinnable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pinnable.swift; sourceTree = "<group>"; };
797799
6A8A6A582BA5B0CA00FBF67D /* MyArticleModel+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MyArticleModel+Views.swift"; sourceTree = "<group>"; };
798800
6A8C3A6628C9361800339A86 /* DotBitKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DotBitKit.swift; sourceTree = "<group>"; };
801+
6A8D7DCB2F49C1420084703B /* PlanetSettingsAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlanetSettingsAIView.swift; sourceTree = "<group>"; };
799802
6A900192296C9F3800BC088E /* MyArticleSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyArticleSettingsView.swift; sourceTree = "<group>"; };
800803
6A941CEC2AF5E7A300CA8261 /* PlanetStore+ServerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlanetStore+ServerInfo.swift"; sourceTree = "<group>"; };
801804
6A96B2292C594F9A00EA3F2A /* QuickPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPostViewModel.swift; sourceTree = "<group>"; };
@@ -1285,6 +1288,7 @@
12851288
children = (
12861289
2AE44EA828CD214800944786 /* PlanetSettingsView.swift */,
12871290
2AE44EAB28CD218200944786 /* PlanetSettingsGeneralView.swift */,
1291+
6A8D7DCB2F49C1420084703B /* PlanetSettingsAIView.swift */,
12881292
6AE93655290CB10700BAC092 /* PlanetSettingsPlanetsView.swift */,
12891293
2AE44EAD28CD219700944786 /* PlanetSettingsModel.swift */,
12901294
2AE44EAF28CD223C00944786 /* PlanetSettingsViewModel.swift */,
@@ -1983,6 +1987,7 @@
19831987
6A43E7D22B873F4900316F81 /* SearchResult.swift in Sources */,
19841988
2A95E6462A19A336001288B8 /* PlanetQuickShare+Extension.swift in Sources */,
19851989
2A39678A2D7ADB2F002CBE7A /* HttpRequest.swift in Sources */,
1990+
6A8D7DCC2F49C14B0084703B /* PlanetSettingsAIView.swift in Sources */,
19861991
2A95E6542A19A39C001288B8 /* PlanetPublishedFolders+Extension.swift in Sources */,
19871992
6A61D5E82B23A234007F761E /* CapsuleBar.swift in Sources */,
19881993
2A95E6532A19A39C001288B8 /* PlanetPublishedServiceStore.swift in Sources */,
@@ -2239,6 +2244,7 @@
22392244
721145EEDE381A8BDED43E93 /* FeedUtils.swift in Sources */,
22402245
721144331A990EB4451F1C1F /* MyPlanetModel.swift in Sources */,
22412246
2AEFEE962C4BA4E700EEB958 /* PlanetAPIModels.swift in Sources */,
2247+
6A8D7DCD2F49C14B0084703B /* PlanetSettingsAIView.swift in Sources */,
22422248
72114A3B2AA58EA6B139AEB8 /* FollowingPlanetModel.swift in Sources */,
22432249
72114C03242147A247A8B317 /* Runner.swift in Sources */,
22442250
6ADD7D882A51B616003D2E54 /* MyArticleModel+Save.swift in Sources */,

Planet/Helper/Extensions.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ extension String {
2828
static let settingsAPIPort: String = "PlanetSettingsAPIPortKey"
2929
static let settingsAPIUsername: String = "PlanetSettingsAPIUsernameKey"
3030
static let settingsAPIPasscode: String = "PlanetSettingsAPIPasscodeKey"
31+
static let settingsAIAPIBase: String = "PlanetSettingsAIAPIBaseKey"
32+
static let settingsAIAPIToken: String = "PlanetSettingsAIAPITokenKey"
33+
static let settingsAIPreferredModel: String = "PlanetSettingsAIPreferredModelKey"
34+
static let settingsAIIsReady: String = "PlanetSettingsAIIsReadyKey"
3135

3236
func sanitized() -> String {
3337
// Reference: https://superuser.com/a/358861
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
//
2+
// PlanetSettingsAIView.swift
3+
// Planet
4+
//
5+
// Created by Xin Liu on 2/21/26.
6+
//
7+
8+
import SwiftUI
9+
10+
struct PlanetSettingsAIView: View {
11+
@State private var aiAPIBase: String = UserDefaults.standard.string(forKey: .settingsAIAPIBase) ?? ""
12+
@State private var aiAPIToken: String = ""
13+
@State private var isShowingToken: Bool = false
14+
@State private var aiPreferredModel: String = UserDefaults.standard.string(forKey: .settingsAIPreferredModel) ?? "claude-sonnet-4-6"
15+
@State private var availableModelIDs: [String] = []
16+
@FocusState private var isModelFieldFocused: Bool
17+
18+
enum ModelStatus {
19+
case idle
20+
case checking
21+
case ok(count: Int, modelFound: Bool)
22+
case error(String)
23+
}
24+
@State private var modelStatus: ModelStatus = .idle
25+
@State private var checkTask: Task<Void, Never>? = nil
26+
@State private var preferredModelCheckTask: Task<Void, Never>? = nil
27+
28+
private var filteredModelIDs: [String] {
29+
let query = aiPreferredModel.trimmingCharacters(in: .whitespaces)
30+
if query.isEmpty { return availableModelIDs }
31+
return availableModelIDs.filter { $0.localizedCaseInsensitiveContains(query) }
32+
}
33+
34+
private var showSuggestions: Bool {
35+
isModelFieldFocused && !availableModelIDs.isEmpty && !filteredModelIDs.isEmpty
36+
}
37+
38+
var body: some View {
39+
Form {
40+
Section {
41+
TextField("API Base URL", text: $aiAPIBase)
42+
.textFieldStyle(.roundedBorder)
43+
.onChange(of: aiAPIBase) { newValue in
44+
UserDefaults.standard.set(newValue, forKey: .settingsAIAPIBase)
45+
scheduleCheck()
46+
}
47+
}
48+
.padding(.top, 6)
49+
50+
Section {
51+
ZStack {
52+
TextField("API Token", text: $aiAPIToken)
53+
.opacity(isShowingToken ? 1.0 : 0.0)
54+
SecureField("API Token", text: $aiAPIToken)
55+
.opacity(!isShowingToken ? 1.0 : 0.0)
56+
HStack {
57+
Spacer()
58+
Button {
59+
isShowingToken.toggle()
60+
} label: {
61+
Image(systemName: !isShowingToken ? "eye.slash" : "eye")
62+
.resizable()
63+
.aspectRatio(contentMode: .fit)
64+
.frame(width: 14, height: 14, alignment: .center)
65+
}
66+
.buttonStyle(.plain)
67+
}
68+
.padding(.horizontal, 8)
69+
}
70+
.textFieldStyle(.roundedBorder)
71+
.onChange(of: aiAPIToken) { newValue in
72+
Task { @MainActor in
73+
do {
74+
if newValue.isEmpty {
75+
try KeychainHelper.shared.delete(forKey: .settingsAIAPIToken)
76+
} else {
77+
try KeychainHelper.shared.saveValue(newValue, forKey: .settingsAIAPIToken)
78+
}
79+
} catch {
80+
debugPrint("failed to save AI API token: \(error)")
81+
}
82+
}
83+
scheduleCheck()
84+
}
85+
}
86+
87+
Section {
88+
TextField("Preferred Model", text: $aiPreferredModel)
89+
.textFieldStyle(.roundedBorder)
90+
.focused($isModelFieldFocused)
91+
.onChange(of: aiPreferredModel) { newValue in
92+
UserDefaults.standard.set(newValue, forKey: .settingsAIPreferredModel)
93+
schedulePreferredModelCheck()
94+
}
95+
96+
if showSuggestions {
97+
ScrollView {
98+
VStack(alignment: .leading, spacing: 0) {
99+
ForEach(filteredModelIDs, id: \.self) { modelID in
100+
Text(modelID)
101+
.font(.system(.body, design: .monospaced))
102+
.padding(.horizontal, 4)
103+
.padding(.vertical, 5)
104+
.frame(maxWidth: .infinity, alignment: .leading)
105+
.contentShape(Rectangle())
106+
.onTapGesture {
107+
aiPreferredModel = modelID
108+
isModelFieldFocused = false
109+
}
110+
if modelID != filteredModelIDs.last {
111+
Divider()
112+
}
113+
}
114+
}
115+
}
116+
.frame(maxHeight: 160)
117+
.background(Color(NSColor.controlBackgroundColor))
118+
.cornerRadius(6)
119+
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2), lineWidth: 1))
120+
}
121+
}
122+
123+
Section {
124+
HStack(spacing: 8) {
125+
statusCircle
126+
statusLabel
127+
}
128+
.padding(.top, 4)
129+
}
130+
131+
Spacer()
132+
}
133+
.padding()
134+
.task {
135+
do {
136+
let token = try KeychainHelper.shared.loadValue(forKey: .settingsAIAPIToken)
137+
if !token.isEmpty {
138+
aiAPIToken = token
139+
}
140+
} catch {
141+
aiAPIToken = ""
142+
}
143+
scheduleCheck()
144+
}
145+
}
146+
147+
@ViewBuilder
148+
private var statusCircle: some View {
149+
switch modelStatus {
150+
case .idle:
151+
Circle()
152+
.frame(width: 10, height: 10)
153+
.foregroundStyle(Color.gray)
154+
case .checking:
155+
ProgressView()
156+
.progressViewStyle(.circular)
157+
.controlSize(.mini)
158+
.frame(width: 10, height: 10)
159+
case .ok(_, let modelFound):
160+
Circle()
161+
.frame(width: 10, height: 10)
162+
.foregroundStyle(modelFound ? Color.green : Color.orange)
163+
case .error:
164+
Circle()
165+
.frame(width: 10, height: 10)
166+
.foregroundStyle(Color.red)
167+
}
168+
}
169+
170+
@ViewBuilder
171+
private var statusLabel: some View {
172+
switch modelStatus {
173+
case .idle:
174+
Text("Not configured")
175+
.foregroundStyle(.secondary)
176+
case .checking:
177+
Text("Checking…")
178+
.foregroundStyle(.secondary)
179+
case .ok(let count, let modelFound):
180+
if modelFound {
181+
Text("\(count) models available, preferred model supported")
182+
} else {
183+
Text("\(count) models available, preferred model not found")
184+
.foregroundStyle(.orange)
185+
}
186+
case .error(let message):
187+
Text(message)
188+
.foregroundStyle(.red)
189+
}
190+
}
191+
192+
private func setModelStatus(_ status: ModelStatus) {
193+
modelStatus = status
194+
if case .ok(_, let modelFound) = status {
195+
UserDefaults.standard.set(modelFound, forKey: .settingsAIIsReady)
196+
} else {
197+
UserDefaults.standard.set(false, forKey: .settingsAIIsReady)
198+
}
199+
}
200+
201+
private func schedulePreferredModelCheck() {
202+
preferredModelCheckTask?.cancel()
203+
let preferredModel = aiPreferredModel
204+
preferredModelCheckTask = Task {
205+
try? await Task.sleep(nanoseconds: 200_000_000)
206+
guard !Task.isCancelled else { return }
207+
await MainActor.run {
208+
guard case .ok(let count, _) = modelStatus else { return }
209+
setModelStatus(.ok(count: count, modelFound: availableModelIDs.contains(preferredModel)))
210+
}
211+
}
212+
}
213+
214+
private func scheduleCheck() {
215+
checkTask?.cancel()
216+
let base = aiAPIBase
217+
let token = aiAPIToken
218+
let preferredModel = aiPreferredModel
219+
guard !base.isEmpty else {
220+
setModelStatus(.idle)
221+
return
222+
}
223+
setModelStatus(.checking)
224+
checkTask = Task {
225+
try? await Task.sleep(nanoseconds: 500_000_000)
226+
guard !Task.isCancelled else { return }
227+
await fetchModels(base: base, token: token, preferredModel: preferredModel)
228+
}
229+
}
230+
231+
private func fetchModels(base: String, token: String, preferredModel: String) async {
232+
let urlString = base.hasSuffix("/") ? "\(base)models" : "\(base)/models"
233+
guard let url = URL(string: urlString) else {
234+
await MainActor.run { setModelStatus(.error("Invalid URL")) }
235+
return
236+
}
237+
var request = URLRequest(url: url)
238+
if !token.isEmpty {
239+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
240+
}
241+
do {
242+
let (data, response) = try await URLSession.shared.data(for: request)
243+
guard let http = response as? HTTPURLResponse else {
244+
await MainActor.run { setModelStatus(.error("Invalid response")) }
245+
return
246+
}
247+
guard http.statusCode == 200 else {
248+
await MainActor.run { setModelStatus(.error("HTTP \(http.statusCode)")) }
249+
return
250+
}
251+
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
252+
let models = json?["data"] as? [[String: Any]] ?? []
253+
let modelIDs = models.compactMap { $0["id"] as? String }.sorted()
254+
let modelFound = modelIDs.contains(preferredModel)
255+
await MainActor.run {
256+
availableModelIDs = modelIDs
257+
setModelStatus(.ok(count: models.count, modelFound: modelFound))
258+
}
259+
} catch {
260+
await MainActor.run { setModelStatus(.error(error.localizedDescription)) }
261+
}
262+
}
263+
}
264+
265+
struct PlanetSettingsAIView_Previews: PreviewProvider {
266+
static var previews: some View {
267+
PlanetSettingsAIView()
268+
}
269+
}

Planet/Settings/PlanetSettingsModel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ enum PlanetSettingsTab: Hashable {
1313
case planets
1414
case api
1515
case publishedFolders
16+
case ai
1617
}

Planet/Settings/PlanetSettingsView.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ struct PlanetSettingsView: View {
2626
.frame(width: 420, height: 320)
2727
.environmentObject(store)
2828

29+
PlanetSettingsAIView()
30+
.tabItem {
31+
Label("AI", systemImage: "sparkles")
32+
}
33+
.tag(PlanetSettingsTab.ai)
34+
.frame(width: 580, height: 360)
35+
2936
PlanetSettingsPlanetsView()
3037
.tabItem {
3138
Label("Planets", systemImage: "tray.full")

0 commit comments

Comments
 (0)