Skip to content

Commit ffe7b23

Browse files
committed
feat(swiftlmchat): HuggingFace live search + font/color fixes
HFModelSearch.swift (new, MLXInferenceCore): - Mirrors Aegis-AI LocalLanguageModels/modelService pattern exactly - HF API: library=mlx, pipeline_tag=text-generation, sort=trending/downloads/likes/lastModified - 300ms debounce on keystroke, pagination (loadMore) - HFModelResult: paramSizeHint, isMoE, downloadsDisplay, likesDisplay ModelPickerView.swift (rewrite): - Segmented tab: Catalog (curated) | Search HF (live search) - HFSearchTab: search bar, sort chips, load-more, error states, empty state - HFModelRow: mlx-community badge, MoE badge, param size badge, download stats - Catalog tab restructured into CatalogTab private struct MessageBubble.swift (rewrite): - NSColor.controlBackgroundColor / UIColor.secondarySystemBackground → adaptive in both light and dark mode (fixes washed-out/invisible bubbles) - .foregroundStyle(.primary) explicit on all message text - Subtle drop shadow for bubble depth - Corner radius 18 (was 16), slightly larger avatar icon
1 parent 6a7449a commit ffe7b23

File tree

4 files changed

+634
-259
lines changed

4 files changed

+634
-259
lines changed
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// HFModelSearch.swift — Live HuggingFace model search for MLX models
2+
//
3+
// API: https://huggingface.co/api/models?library=mlx&pipeline_tag=text-generation
4+
// &search=<query>&sort=trending&limit=20&full=false
5+
//
6+
// Mirrors the Aegis-AI pattern (LocalLanguageModels.tsx + modelService.ts):
7+
// library=mlx → filters to MLX-format models (mlx-community and others)
8+
// pipeline_tag → text-generation or text2text-generation
9+
// sort → trending (default), downloads, likes, lastModified
10+
11+
import Foundation
12+
13+
// MARK: — HF API model result
14+
15+
public struct HFModelResult: Identifiable, Sendable, Decodable {
16+
public let id: String // e.g. "mlx-community/Qwen2.5-7B-Instruct-4bit"
17+
public let likes: Int?
18+
public let downloads: Int?
19+
public let pipeline_tag: String? // "text-generation"
20+
public let tags: [String]?
21+
22+
// Computed helpers
23+
public var repoOwner: String { String(id.split(separator: "/").first ?? "") }
24+
public var repoName: String { String(id.split(separator: "/").last ?? "") }
25+
public var isMlxCommunity: Bool { repoOwner == "mlx-community" }
26+
27+
/// Best-effort parameter size extracted from the model ID name.
28+
public var paramSizeHint: String? {
29+
let patterns = [
30+
#"(\d+\.?\d*)[Bb]"#, // 7B, 0.5B, 3.8B
31+
#"(\d+)[xX](\d+)[Bb]"# // 8x7B MoE
32+
]
33+
for pattern in patterns {
34+
if let match = repoName.range(of: pattern, options: .regularExpression) {
35+
return String(repoName[match])
36+
}
37+
}
38+
return nil
39+
}
40+
41+
/// True if the model name suggests MoE architecture.
42+
public var isMoE: Bool {
43+
let lower = repoName.lowercased()
44+
return lower.contains("moe") || lower.contains("-a") || lower.contains("_a")
45+
}
46+
47+
public var downloadsDisplay: String {
48+
guard let d = downloads else { return "" }
49+
if d >= 1_000_000 { return String(format: "%.1fM↓", Double(d) / 1_000_000) }
50+
if d >= 1_000 { return String(format: "%.0fk↓", Double(d) / 1_000) }
51+
return "\(d)"
52+
}
53+
54+
public var likesDisplay: String {
55+
guard let l = likes, l > 0 else { return "" }
56+
if l >= 1_000 { return String(format: "%.0fk♥", Double(l) / 1_000) }
57+
return "\(l)"
58+
}
59+
}
60+
61+
// MARK: — Sort options (matching Aegis-AI LocalLanguageModels sort selector)
62+
63+
public enum HFSortOption: String, CaseIterable, Sendable {
64+
case trending = "trending"
65+
case downloads = "downloads"
66+
case likes = "likes"
67+
case lastModified = "lastModified"
68+
69+
public var label: String {
70+
switch self {
71+
case .trending: return "Trending"
72+
case .downloads: return "Downloads"
73+
case .likes: return "Likes"
74+
case .lastModified: return "Newest"
75+
}
76+
}
77+
}
78+
79+
// MARK: — HFModelSearchService
80+
81+
@MainActor
82+
public final class HFModelSearchService: ObservableObject {
83+
public static let shared = HFModelSearchService()
84+
85+
@Published public var results: [HFModelResult] = []
86+
@Published public var isSearching = false
87+
@Published public var errorMessage: String? = nil
88+
@Published public var hasMore = false
89+
90+
private let hfBase = "https://huggingface.co/api/models"
91+
private let pageSize = 20
92+
private var currentOffset = 0
93+
private var currentQuery = ""
94+
private var currentSort = HFSortOption.trending
95+
private var debounceTask: Task<Void, Never>? = nil
96+
97+
private init() {}
98+
99+
// MARK: — Public API
100+
101+
/// Debounced search — safe to call on every keystroke.
102+
public func search(query: String, sort: HFSortOption = .trending) {
103+
debounceTask?.cancel()
104+
debounceTask = Task {
105+
// 300ms debounce
106+
try? await Task.sleep(nanoseconds: 300_000_000)
107+
guard !Task.isCancelled else { return }
108+
currentQuery = query
109+
currentSort = sort
110+
currentOffset = 0
111+
results = []
112+
await fetchPage()
113+
}
114+
}
115+
116+
/// Load next page of results.
117+
public func loadMore() {
118+
guard hasMore, !isSearching else { return }
119+
Task { await fetchPage() }
120+
}
121+
122+
// MARK: — Private
123+
124+
private func fetchPage() async {
125+
isSearching = true
126+
errorMessage = nil
127+
128+
var components = URLComponents(string: hfBase)!
129+
var queryItems: [URLQueryItem] = [
130+
// MLX format filter (matches Aegis-AI: library_name=mlx)
131+
URLQueryItem(name: "library", value: "mlx"),
132+
URLQueryItem(name: "pipeline_tag", value: "text-generation"),
133+
URLQueryItem(name: "sort", value: currentSort.rawValue),
134+
URLQueryItem(name: "limit", value: "\(pageSize)"),
135+
URLQueryItem(name: "offset", value: "\(currentOffset)"),
136+
URLQueryItem(name: "full", value: "false"),
137+
]
138+
if !currentQuery.isEmpty {
139+
queryItems.append(URLQueryItem(name: "search", value: currentQuery))
140+
}
141+
components.queryItems = queryItems
142+
143+
guard let url = components.url else {
144+
isSearching = false
145+
return
146+
}
147+
148+
do {
149+
let (data, response) = try await URLSession.shared.data(from: url)
150+
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
151+
errorMessage = "HuggingFace search unavailable"
152+
isSearching = false
153+
return
154+
}
155+
let page = try JSONDecoder().decode([HFModelResult].self, from: data)
156+
results.append(contentsOf: page)
157+
hasMore = page.count == pageSize
158+
currentOffset += page.count
159+
} catch is CancellationError {
160+
// no-op
161+
} catch {
162+
errorMessage = "Search failed: \(error.localizedDescription)"
163+
}
164+
165+
isSearching = false
166+
}
167+
}

SwiftLMChat/SwiftLMChat/Views/MessageBubble.swift

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
11
// MessageBubble.swift — Chat message bubble + live streaming bubble
22
import SwiftUI
33

4+
// MARK: — Shared adaptive colors
5+
6+
private extension Color {
7+
/// Assistant bubble background — works in both light and dark mode.
8+
/// Light mode: warm near-white. Dark mode: elevated dark fill.
9+
static var assistantBubble: Color {
10+
#if os(macOS)
11+
return Color(NSColor.controlBackgroundColor)
12+
#else
13+
return Color(UIColor.secondarySystemBackground)
14+
#endif
15+
}
16+
17+
/// Subtle inner tint for thinking disclosure group.
18+
static var thinkingBubble: Color {
19+
#if os(macOS)
20+
return Color(NSColor.windowBackgroundColor)
21+
#else
22+
return Color(UIColor.tertiarySystemBackground)
23+
#endif
24+
}
25+
}
26+
427
// MARK: — Static Message Bubble
528

629
struct MessageBubble: View {
@@ -15,27 +38,32 @@ struct MessageBubble: View {
1538
if !isUser {
1639
// Avatar
1740
Circle()
18-
.fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing))
41+
.fill(LinearGradient(
42+
colors: [.blue, .purple],
43+
startPoint: .topLeading, endPoint: .bottomTrailing
44+
))
1945
.frame(width: 28, height: 28)
2046
.overlay(
2147
Image(systemName: "cpu")
22-
.font(.caption2)
48+
.font(.system(size: 11, weight: .semibold))
2349
.foregroundStyle(.white)
2450
)
2551
}
2652

2753
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
2854
Text(message.content)
55+
.font(.system(.body, design: .default))
2956
.textSelection(.enabled)
3057
.padding(.horizontal, 12)
3158
.padding(.vertical, 8)
32-
.background(isUser ? Color.accentColor : Color(white: 0.92))
59+
.background(isUser ? Color.accentColor : Color.assistantBubble)
3360
.foregroundStyle(isUser ? .white : .primary)
3461
.clipShape(BubbleShape(isUser: isUser))
62+
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
3563

3664
Text(message.timestamp, style: .time)
3765
.font(.caption2)
38-
.foregroundStyle(.secondary)
66+
.foregroundStyle(.tertiary)
3967
}
4068

4169
if !isUser { Spacer(minLength: 60) }
@@ -48,32 +76,35 @@ struct MessageBubble: View {
4876
struct StreamingBubble: View {
4977
let text: String
5078
let thinkingText: String?
51-
@State private var dotPhase = 0
79+
@State private var cursorVisible = true
5280

5381
var body: some View {
5482
HStack(alignment: .bottom, spacing: 8) {
5583
Circle()
56-
.fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing))
84+
.fill(LinearGradient(
85+
colors: [.blue, .purple],
86+
startPoint: .topLeading, endPoint: .bottomTrailing
87+
))
5788
.frame(width: 28, height: 28)
5889
.overlay(
5990
Image(systemName: "cpu")
60-
.font(.caption2)
91+
.font(.system(size: 11, weight: .semibold))
6192
.foregroundStyle(.white)
6293
)
6394

6495
VStack(alignment: .leading, spacing: 6) {
65-
// Thinking section (collapsed by default, shows thought process)
96+
// Thinking section
6697
if let thinking = thinkingText, !thinking.isEmpty {
6798
DisclosureGroup {
6899
Text(thinking)
69100
.font(.caption)
70101
.foregroundStyle(.secondary)
71102
.padding(8)
72-
.background(Color(white: 0.95))
73-
.clipShape(RoundedRectangle(cornerRadius: 8))
103+
.frame(maxWidth: .infinity, alignment: .leading)
104+
.background(Color.thinkingBubble, in: RoundedRectangle(cornerRadius: 8))
74105
} label: {
75106
Label("Thinking…", systemImage: "brain")
76-
.font(.caption)
107+
.font(.caption.weight(.medium))
77108
.foregroundStyle(.secondary)
78109
}
79110
}
@@ -82,31 +113,33 @@ struct StreamingBubble: View {
82113
if !text.isEmpty {
83114
HStack(alignment: .bottom, spacing: 2) {
84115
Text(text)
116+
.font(.system(.body, design: .default))
117+
.foregroundStyle(.primary)
85118
.textSelection(.enabled)
86119
// Blinking cursor
87120
RoundedRectangle(cornerRadius: 1)
88121
.frame(width: 2, height: 16)
89122
.foregroundStyle(.blue)
90-
.opacity(dotPhase % 2 == 0 ? 1 : 0)
91-
.animation(.easeInOut(duration: 0.5).repeatForever(), value: dotPhase)
123+
.opacity(cursorVisible ? 1 : 0)
124+
.animation(.easeInOut(duration: 0.5).repeatForever(), value: cursorVisible)
92125
}
93126
.padding(.horizontal, 12)
94127
.padding(.vertical, 8)
95-
.background(Color(white: 0.92))
128+
.background(Color.assistantBubble)
96129
.clipShape(BubbleShape(isUser: false))
130+
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
97131
} else {
98-
// Typing dots when no text yet
99132
TypingIndicator()
100133
.padding(.horizontal, 12)
101-
.padding(.vertical, 8)
102-
.background(Color(white: 0.92))
134+
.padding(.vertical, 10)
135+
.background(Color.assistantBubble)
103136
.clipShape(BubbleShape(isUser: false))
104137
}
105138
}
106139

107140
Spacer(minLength: 60)
108141
}
109-
.onAppear { dotPhase = 1 }
142+
.onAppear { cursorVisible = false }
110143
}
111144
}
112145

@@ -116,37 +149,36 @@ struct TypingIndicator: View {
116149
@State private var phase = 0
117150

118151
var body: some View {
119-
HStack(spacing: 4) {
152+
HStack(spacing: 5) {
120153
ForEach(0..<3) { i in
121154
Circle()
122-
.frame(width: 6, height: 6)
155+
.frame(width: 7, height: 7)
123156
.foregroundStyle(.secondary)
124-
.scaleEffect(phase == i ? 1.3 : 0.8)
157+
.scaleEffect(phase == i ? 1.4 : 0.8)
125158
.animation(
126-
.easeInOut(duration: 0.4).repeatForever().delay(Double(i) * 0.15),
159+
.easeInOut(duration: 0.45)
160+
.repeatForever()
161+
.delay(Double(i) * 0.15),
127162
value: phase
128163
)
129164
}
130165
}
131-
.onAppear {
132-
withAnimation { phase = 1 }
133-
}
166+
.onAppear { withAnimation { phase = 1 } }
134167
}
135168
}
136169

137170
// MARK: — Bubble Shape
138171

139172
struct BubbleShape: Shape {
140173
let isUser: Bool
141-
let radius: CGFloat = 16
174+
let radius: CGFloat = 18
142175

143176
func path(in rect: CGRect) -> Path {
177+
let tl: CGFloat = isUser ? radius : 4
178+
let tr: CGFloat = isUser ? 4 : radius
179+
let bl: CGFloat = radius
180+
let br: CGFloat = radius
144181
var path = Path()
145-
let tl = isUser ? radius : 4
146-
let tr = isUser ? 4 : radius
147-
let bl = radius
148-
let br = radius
149-
150182
path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
151183
path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
152184
path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY + tr),

0 commit comments

Comments
 (0)