Skip to content

Commit 8816815

Browse files
committed
feat(ModelSelection): implement multi-keyword search and highlight for models
1 parent 1695a45 commit 8816815

File tree

1 file changed

+139
-65
lines changed

1 file changed

+139
-65
lines changed

Chato/Views/ChatSetting/ModelSelectionView.swift

Lines changed: 139 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,54 @@ struct ModelSelectionContent: View {
3737
@Binding var favoritesExpanded: Bool
3838
let dismiss: DismissAction
3939

40-
private var favoritedModels: [ModelEntity] {
40+
private func favoritedModels() -> [ModelEntity] {
4141
let filtered = allModels.filter { $0.favorited }
42-
return ModelEntity.smartSort(filtered)
42+
let sorted = ModelEntity.smartSort(filtered)
43+
return sorted
44+
}
45+
46+
private func searchKeywords() -> [String] {
47+
return parseSearchText(searchText)
4348
}
4449

4550
private var filteredModels: [ModelEntity] {
46-
if searchText.isEmpty {
51+
let keywords = searchKeywords()
52+
if keywords.isEmpty {
4753
return allModels
4854
}
49-
return allModels.filter { model in
50-
model.resolvedName.localizedStandardContains(searchText) ||
51-
model.modelId.localizedStandardContains(searchText)
55+
let filtered = allModels.filter { model in
56+
let nameMatches = matchesKeywords(text: model.resolvedName, keywords: keywords)
57+
let idMatches = matchesKeywords(text: model.modelId, keywords: keywords)
58+
return nameMatches || idMatches
59+
}
60+
return filtered
61+
}
62+
63+
private func parseSearchText(_ text: String) -> [String] {
64+
let separators = CharacterSet(charactersIn: " ,")
65+
let keywords = text.components(separatedBy: separators)
66+
.map { $0.trimmingCharacters(in: .whitespaces) }
67+
.filter { !$0.isEmpty }
68+
return keywords
69+
}
70+
71+
private func matchesKeywords(text: String, keywords: [String]) -> Bool {
72+
guard !keywords.isEmpty else { return true }
73+
74+
let lowercasedText = text.lowercased()
75+
return keywords.contains { keyword in
76+
lowercasedText.contains(keyword.lowercased())
5277
}
5378
}
5479

5580
private var groupedProviders: [(provider: Provider, models: [ModelEntity])] {
56-
let filtered = searchText.isEmpty ? allModels : filteredModels
57-
return filtered.groupedByProvider()
81+
let modelsToGroup: [ModelEntity]
82+
if searchText.isEmpty {
83+
modelsToGroup = allModels
84+
} else {
85+
modelsToGroup = filteredModels
86+
}
87+
return modelsToGroup.groupedByProvider()
5888
}
5989

6090
var body: some View {
@@ -83,28 +113,26 @@ struct ModelSelectionContent: View {
83113

84114
@ViewBuilder
85115
private var favoritesSection: some View {
86-
if !favoritedModels.isEmpty && searchText.isEmpty {
87-
favoritesSectionContent
88-
}
89-
}
90-
91-
private var favoritesSectionContent: some View {
92-
DisclosureGroup(
93-
isExpanded: $favoritesExpanded
94-
) {
95-
ForEach(favoritedModels) { model in
96-
ModelSelectionRow(
97-
model: model,
98-
isSelected: model.id == chatOption.model?.id,
99-
showProvider: true
100-
) {
101-
selectModel(model)
116+
let favorited = favoritedModels()
117+
if !favorited.isEmpty && searchText.isEmpty {
118+
DisclosureGroup(
119+
isExpanded: $favoritesExpanded
120+
) {
121+
ForEach(favorited) { model in
122+
ModelSelectionRow(
123+
model: model,
124+
isSelected: model.id == chatOption.model?.id,
125+
showProvider: true,
126+
searchKeywords: searchKeywords()
127+
) {
128+
selectModel(model)
129+
}
130+
.id(model.id)
102131
}
103-
.id(model.id)
132+
} label: {
133+
Label("Favorites", systemImage: "star.fill")
134+
.foregroundColor(.yellow)
104135
}
105-
} label: {
106-
Label("Favorites", systemImage: "star.fill")
107-
.foregroundColor(.yellow)
108136
}
109137
}
110138

@@ -123,7 +151,8 @@ struct ModelSelectionContent: View {
123151
ModelSelectionRow(
124152
model: model,
125153
isSelected: model.id == chatOption.model?.id,
126-
showProvider: false
154+
showProvider: false,
155+
searchKeywords: searchKeywords()
127156
) {
128157
selectModel(model)
129158
}
@@ -179,8 +208,9 @@ struct ModelSelectionContent: View {
179208
guard expandedProviders.isEmpty else { return }
180209

181210
// Check if selected model exists and is not in favorites
211+
let favorited = favoritedModels()
182212
if let selectedModel = chatOption.model,
183-
!favoritedModels.contains(where: { $0.id == selectedModel.id }) {
213+
!favorited.contains(where: { $0.id == selectedModel.id }) {
184214
// Find and expand only the provider containing the selected model
185215
if let providerGroup = groupedProviders.first(where: { group in
186216
group.models.contains(where: { $0.id == selectedModel.id })
@@ -210,52 +240,96 @@ struct ModelSelectionRow: View {
210240
let model: ModelEntity
211241
let isSelected: Bool
212242
let showProvider: Bool
243+
let searchKeywords: [String]
213244
let action: () -> Void
214245

215246
var body: some View {
216-
Button(action: action) {
217-
HStack(spacing: 12) {
218-
if model.favorited {
219-
Image(systemName: "star.fill")
220-
.foregroundColor(.yellow)
221-
.font(.caption)
222-
}
223-
224-
VStack(alignment: .leading, spacing: 4) {
225-
Text(model.resolvedName)
247+
HStack(spacing: 12) {
248+
Button(action: action) {
249+
HStack(spacing: 12) {
250+
VStack(alignment: .leading, spacing: 4) {
251+
HighlightedText(
252+
text: model.resolvedName,
253+
keywords: searchKeywords
254+
)
226255
.font(.body)
227256
.foregroundColor(.primary)
228-
229-
HStack(spacing: 8) {
230-
if showProvider {
231-
Label(model.provider.displayName, systemImage: model.provider.iconName)
232-
.font(.caption)
233-
.foregroundColor(.secondary)
234-
}
235257

236-
if model.isCustom {
237-
Label("Custom", systemImage: "wrench")
238-
.font(.caption2)
239-
.foregroundColor(.blue)
240-
}
241-
242-
if let contextLength = model.contextLength {
243-
Text("\(contextLength)k")
244-
.font(.caption)
245-
.foregroundColor(.secondary)
258+
HStack(spacing: 8) {
259+
if showProvider {
260+
Label(model.provider.displayName, systemImage: model.provider.iconName)
261+
.font(.caption)
262+
.foregroundColor(.secondary)
263+
}
264+
265+
if model.isCustom {
266+
Label("Custom", systemImage: "wrench")
267+
.font(.caption2)
268+
.foregroundColor(.blue)
269+
}
270+
271+
if let contextLength = model.contextLength {
272+
Text("\(contextLength)k")
273+
.font(.caption)
274+
.foregroundColor(.secondary)
275+
}
246276
}
247277
}
278+
279+
Spacer()
280+
281+
if isSelected {
282+
Image(systemName: "checkmark")
283+
.foregroundColor(.accentColor)
284+
}
248285
}
249-
250-
Spacer()
251-
252-
if isSelected {
253-
Image(systemName: "checkmark")
254-
.foregroundColor(.accentColor)
286+
.contentShape(Rectangle())
287+
}
288+
.buttonStyle(.plain)
289+
290+
Button {
291+
withAnimation {
292+
model.favorited.toggle()
255293
}
294+
} label: {
295+
Image(systemName: model.favorited ? "star.fill" : "star")
296+
.foregroundColor(model.favorited ? .yellow : .gray)
256297
}
257-
.contentShape(Rectangle())
298+
.buttonStyle(.plain)
258299
}
259-
.buttonStyle(.plain)
300+
}
301+
}
302+
303+
struct HighlightedText: View {
304+
let text: String
305+
let keywords: [String]
306+
307+
var body: some View {
308+
if keywords.isEmpty {
309+
Text(text)
310+
} else {
311+
Text(attributedString)
312+
}
313+
}
314+
315+
private var attributedString: AttributedString {
316+
var attributed = AttributedString(text)
317+
318+
let lowercasedText = text.lowercased()
319+
320+
for keyword in keywords {
321+
let lowercasedKeyword = keyword.lowercased()
322+
var searchRange = lowercasedText.startIndex..<lowercasedText.endIndex
323+
324+
while let range = lowercasedText.range(of: lowercasedKeyword, options: [], range: searchRange) {
325+
if let attributedRange = Range(range, in: attributed) {
326+
attributed[attributedRange].backgroundColor = .yellow.opacity(0.3)
327+
attributed[attributedRange].font = .body.bold()
328+
}
329+
searchRange = range.upperBound..<lowercasedText.endIndex
330+
}
331+
}
332+
333+
return attributed
260334
}
261335
}

0 commit comments

Comments
 (0)