Skip to content

Commit 012d188

Browse files
committed
Add chat model management view
1 parent 1c6af3f commit 012d188

File tree

11 files changed

+911
-24
lines changed

11 files changed

+911
-24
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import ComposableArchitecture
2+
import Foundation
3+
4+
struct APIKeySubmission: ReducerProtocol {
5+
struct State: Equatable {
6+
@BindingState var name: String = ""
7+
@BindingState var key: String = ""
8+
}
9+
10+
enum Action: Equatable, BindableAction {
11+
case binding(BindingAction<State>)
12+
case saveButtonClicked
13+
case cancelButtonClicked
14+
}
15+
16+
var body: some ReducerProtocol<State, Action> {
17+
BindingReducer()
18+
19+
Reduce { state, action in
20+
switch action {
21+
case .saveButtonClicked:
22+
return .none
23+
24+
case .cancelButtonClicked:
25+
return .none
26+
27+
case .binding:
28+
return .none
29+
}
30+
}
31+
}
32+
}

Core/Sources/HostApp/AccountSettings/ChatModel.swift

Lines changed: 0 additions & 16 deletions
This file was deleted.
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import AIModel
2+
import ComposableArchitecture
3+
import Dependencies
4+
import Keychain
5+
import OpenAIService
6+
import Preferences
7+
import SwiftUI
8+
9+
struct APIKeyKeychainDependencyKey: DependencyKey {
10+
static var liveValue: KeychainType = Keychain.apiKey
11+
static var previewValue: KeychainType = FakeKeyChain()
12+
static var testValue: KeychainType = FakeKeyChain()
13+
}
14+
15+
extension DependencyValues {
16+
var apiKeyKeychain: KeychainType {
17+
get { self[APIKeyKeychainDependencyKey.self] }
18+
set { self[APIKeyKeychainDependencyKey.self] = newValue }
19+
}
20+
}
21+
22+
struct ChatModelEdit: ReducerProtocol {
23+
struct State: Equatable, Identifiable {
24+
var id: String
25+
@BindingState var name: String
26+
@BindingState var format: ChatModel.Format
27+
@BindingState var apiKeyName: String = ""
28+
@BindingState var baseURL: String = ""
29+
@BindingState var maxTokens: Int = 4000
30+
@BindingState var supportsFunctionCalling: Bool = true
31+
@BindingState var modelName: String = ""
32+
var availableModelNames: [String] = []
33+
var availableAPIKeys: [String] = []
34+
var isTesting = false
35+
var suggestedMaxTokens: Int?
36+
@PresentationState var apiKeySubmission: APIKeySubmission.State?
37+
}
38+
39+
enum Action: Equatable, BindableAction {
40+
case binding(BindingAction<State>)
41+
case appear
42+
case saveButtonClicked
43+
case cancelButtonClicked
44+
case refreshAvailableModelNames
45+
case refreshAvailableAPIKeys
46+
case testButtonClicked
47+
case testSucceeded(String)
48+
case testFailed(String)
49+
case createAPIKeyButtonClicked
50+
case checkSuggestedMaxTokens
51+
case apiKeySubmission(PresentationAction<APIKeySubmission.Action>)
52+
}
53+
54+
@Dependency(\.toast) var toast
55+
@Dependency(\.apiKeyKeychain) var keychain
56+
57+
var body: some ReducerProtocol<State, Action> {
58+
BindingReducer()
59+
60+
Reduce { state, action in
61+
switch action {
62+
case .appear:
63+
return .merge([
64+
.run { await $0(.refreshAvailableAPIKeys) },
65+
.run { await $0(.refreshAvailableModelNames) },
66+
.run { await $0(.checkSuggestedMaxTokens) },
67+
])
68+
69+
case .saveButtonClicked:
70+
return .none
71+
72+
case .cancelButtonClicked:
73+
return .none
74+
75+
case .testButtonClicked:
76+
guard !state.isTesting else { return .none }
77+
state.isTesting = true
78+
let model = ChatModel(
79+
id: state.id,
80+
name: state.name,
81+
format: state.format,
82+
info: .init(
83+
apiKeyName: state.apiKeyName,
84+
baseURL: state.baseURL,
85+
maxTokens: state.maxTokens,
86+
supportsFunctionCalling: state.supportsFunctionCalling,
87+
modelName: state.modelName
88+
)
89+
)
90+
return .run { send in
91+
do {
92+
let reply =
93+
try await ChatGPTService(
94+
configuration: UserPreferenceChatGPTConfiguration()
95+
.overriding {
96+
$0.model = model
97+
}
98+
).sendAndWait(content: "Hello")
99+
await send(.testSucceeded(reply ?? "No Message"))
100+
} catch {
101+
await send(.testFailed(error.localizedDescription))
102+
}
103+
}
104+
105+
case let .testSucceeded(message):
106+
state.isTesting = false
107+
toast(message, .info)
108+
return .none
109+
110+
case let .testFailed(message):
111+
state.isTesting = false
112+
toast(message, .error)
113+
return .none
114+
115+
case .refreshAvailableModelNames:
116+
if state.format == .openAI {
117+
state.availableModelNames = ChatGPTModel.allCases.map(\.rawValue)
118+
}
119+
120+
return .none
121+
122+
case .refreshAvailableAPIKeys:
123+
do {
124+
let pairs = try keychain.getAll()
125+
state.availableAPIKeys = Array(pairs.keys)
126+
} catch {
127+
toast(error.localizedDescription, .error)
128+
}
129+
130+
return .none
131+
132+
case .createAPIKeyButtonClicked:
133+
state.apiKeySubmission = .init()
134+
return .none
135+
136+
case .apiKeySubmission(.presented(.saveButtonClicked)):
137+
if let key = state.apiKeySubmission {
138+
do {
139+
try keychain.update(key.name, key: key.key)
140+
} catch {
141+
toast(error.localizedDescription, .error)
142+
}
143+
}
144+
state.apiKeySubmission = nil
145+
return .none
146+
147+
case .apiKeySubmission(.presented(.cancelButtonClicked)):
148+
state.apiKeySubmission = nil
149+
return .none
150+
151+
case .apiKeySubmission:
152+
return .none
153+
154+
case .checkSuggestedMaxTokens:
155+
guard state.format == .openAI,
156+
let knownModel = ChatGPTModel(rawValue: state.modelName)
157+
else {
158+
state.suggestedMaxTokens = nil
159+
return .none
160+
}
161+
state.suggestedMaxTokens = knownModel.maxToken
162+
return .none
163+
164+
case .binding(\.$format):
165+
return .merge([
166+
.run { await $0(.refreshAvailableAPIKeys) },
167+
.run { await $0(.refreshAvailableModelNames) },
168+
.run { await $0(.checkSuggestedMaxTokens) },
169+
])
170+
171+
case .binding(\.$modelName):
172+
return .run { send in
173+
await send(.checkSuggestedMaxTokens)
174+
}
175+
176+
case .binding:
177+
return .none
178+
}
179+
}
180+
.ifLet(\.$apiKeySubmission, action: /Action.apiKeySubmission) {
181+
APIKeySubmission()
182+
}
183+
}
184+
}
185+
186+
extension ChatModelEdit.State {
187+
init(model: ChatModel) {
188+
self.init(
189+
id: model.id,
190+
name: model.name,
191+
format: model.format,
192+
apiKeyName: model.info.apiKeyName,
193+
baseURL: model.info.baseURL,
194+
maxTokens: model.info.maxTokens,
195+
supportsFunctionCalling: model.info.supportsFunctionCalling,
196+
modelName: model.info.modelName
197+
)
198+
}
199+
}
200+
201+
extension ChatModel {
202+
init(state: ChatModelEdit.State) {
203+
self.init(
204+
id: state.id,
205+
name: state.name,
206+
format: state.format,
207+
info: .init(
208+
apiKeyName: state.apiKeyName,
209+
baseURL: state.baseURL,
210+
maxTokens: state.maxTokens,
211+
supportsFunctionCalling: state.supportsFunctionCalling,
212+
modelName: state.modelName
213+
)
214+
)
215+
}
216+
}
217+
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import AIModel
2+
import ComposableArchitecture
3+
import Keychain
4+
import Preferences
5+
import SwiftUI
6+
7+
struct ChatModelManagement: ReducerProtocol {
8+
struct State: Equatable {
9+
var models: IdentifiedArray<String, ChatModel>
10+
@PresentationState var editingModel: ChatModelEdit.State?
11+
}
12+
13+
enum Action: Equatable {
14+
case appear
15+
case createModel
16+
case removeModel(id: String)
17+
case selectModel(id: String)
18+
case duplicateModel(id: String)
19+
case moveModel(from: IndexSet, to: Int)
20+
case chatModelItem(PresentationAction<ChatModelEdit.Action>)
21+
}
22+
23+
@Dependency(\.toast) var toast
24+
var userDefaults: UserDefaults = .shared
25+
26+
var body: some ReducerProtocol<State, Action> {
27+
Reduce { state, action in
28+
switch action {
29+
case .appear:
30+
if isPreview { return .none }
31+
state.models = .init(
32+
userDefaults.value(for: \.chatModels),
33+
id: \.id,
34+
uniquingIDsWith: { a, _ in a }
35+
)
36+
37+
return .none
38+
39+
case .createModel:
40+
state.editingModel = .init(
41+
id: UUID().uuidString,
42+
name: "New Model",
43+
format: .openAI
44+
)
45+
return .none
46+
47+
case let .removeModel(id):
48+
state.models.remove(id: id)
49+
persist(state)
50+
return .none
51+
52+
case let .selectModel(id):
53+
guard let model = state.models[id: id] else { return .none }
54+
state.editingModel = .init(model: model)
55+
return .none
56+
57+
case let .duplicateModel(id):
58+
guard var model = state.models[id: id] else { return .none }
59+
model.id = UUID().uuidString
60+
model.name += " (Copy)"
61+
62+
if let index = state.models.index(id: id) {
63+
state.models.insert(model, at: index + 1)
64+
} else {
65+
state.models.append(model)
66+
}
67+
persist(state)
68+
return .none
69+
70+
case let .moveModel(from, to):
71+
state.models.move(fromOffsets: from, toOffset: to)
72+
persist(state)
73+
return .none
74+
75+
case .chatModelItem(.presented(.saveButtonClicked)):
76+
guard let editingModel = state.editingModel, validateModel(editingModel)
77+
else { return .none }
78+
79+
if let index = state.models
80+
.firstIndex(where: { $0.id == editingModel.id })
81+
{
82+
state.models[index] = .init(state: editingModel)
83+
} else {
84+
state.models.append(.init(state: editingModel))
85+
}
86+
persist(state)
87+
return .run { send in
88+
await send(.chatModelItem(.dismiss))
89+
}
90+
91+
case .chatModelItem(.presented(.cancelButtonClicked)):
92+
return .run { send in
93+
await send(.chatModelItem(.dismiss))
94+
}
95+
96+
case .chatModelItem:
97+
return .none
98+
}
99+
}.ifLet(\.$editingModel, action: /Action.chatModelItem) {
100+
ChatModelEdit()
101+
}
102+
}
103+
104+
func persist(_ state: State) {
105+
let models = state.models
106+
userDefaults.set(Array(models), for: \.chatModels)
107+
}
108+
109+
func validateModel(_ chatModel: ChatModelEdit.State) -> Bool {
110+
guard !chatModel.name.isEmpty else {
111+
toast("Model name cannot be empty", .error)
112+
return false
113+
}
114+
guard !chatModel.id.isEmpty else {
115+
toast("Model ID cannot be empty", .error)
116+
return false
117+
}
118+
119+
guard !chatModel.modelName.isEmpty else {
120+
toast("Model name cannot be empty", .error)
121+
return false
122+
}
123+
return true
124+
}
125+
}
126+

0 commit comments

Comments
 (0)