Skip to content

Commit c684488

Browse files
authored
add a minimal LLM chat example + switch to mlx-swift 0.30.2 (#454)
* add a minimal LLM chat example - LLMEval has more of a showcase of features and runtime statistics - this provides the minimum required to load a model and interact with it - also cleans up the xcodeproj (see #451) - removes VLMEval (redundant and wasn't maintained) * remove tests that have migrated to mlx-swift-lm * remove ExampleLLM -- this will be covered by other examples * rework llm-tool to use ChatSession -- a better example * workaround for CI failures * update to mlx-swift 0.30.3 and mlx-swift-lm 2.30.3
1 parent 44b14cf commit c684488

49 files changed

Lines changed: 1151 additions & 2347 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pull_request.yml

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Build and Test
22

3-
on: pull_request
3+
on: pull_request
44

55
permissions:
66
contents: read
@@ -14,13 +14,13 @@ jobs:
1414
steps:
1515
- uses: actions/checkout@v6
1616
with:
17-
submodules: recursive
17+
submodules: recursive
1818

1919
- name: Setup uv
2020
uses: astral-sh/setup-uv@v6
2121
with:
22-
activate-environment: true
23-
22+
activate-environment: true
23+
2424
- name: Setup pre-commit
2525
shell: sh
2626
run: |
@@ -58,17 +58,16 @@ jobs:
5858
run: |
5959
cd /tmp/swift-format
6060
ln -s "$(swift build --show-bin-path -c release)/swift-format" /usr/local/bin/swift-format
61-
61+
6262
- name: Configure safe directory for git
6363
shell: sh
6464
run: |
6565
git config --global --add safe.directory "$GITHUB_WORKSPACE"
66-
66+
6767
- name: Run style checks
6868
shell: sh
6969
run: |
7070
pre-commit run --all || (echo "Style checks failed, please install pre-commit and run pre-commit run --all and push the change"; echo ""; git --no-pager diff; exit 1)
71-
7271
7372
mac_build_and_test:
7473
needs: lint
@@ -78,13 +77,19 @@ jobs:
7877
- uses: actions/checkout@v6
7978
with:
8079
submodules: recursive
81-
80+
8281
- name: Verify MetalToolchain installed
8382
shell: bash
83+
env:
84+
# workaround for CI failure
85+
DEVELOPER_DIR: /Applications/Xcode-latest.app
8486
run: xcodebuild -showComponent MetalToolchain
85-
87+
8688
- name: Build Package (Xcode, macOS)
8789
shell: sh
90+
env:
91+
# workaround for CI failure
92+
DEVELOPER_DIR: /Applications/Xcode-latest.app
8893
run: |
8994
xcodebuild -version
9095
xcrun --show-sdk-build-version
@@ -94,6 +99,9 @@ jobs:
9499
95100
- name: Build tools (Xcode, macOS)
96101
shell: sh
102+
env:
103+
# workaround for CI failure
104+
DEVELOPER_DIR: /Applications/Xcode-latest.app
97105
run: |
98106
xcodebuild -version
99107
xcrun --show-sdk-build-version

Applications/VLMEval/Assets.xcassets/AccentColor.colorset/Contents.json renamed to Applications/LLMBasic/Assets.xcassets/AccentColor.colorset/Contents.json

File renamed without changes.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
{
2+
"images" : [
3+
{
4+
"idiom" : "universal",
5+
"platform" : "ios",
6+
"size" : "1024x1024"
7+
},
8+
{
9+
"appearances" : [
10+
{
11+
"appearance" : "luminosity",
12+
"value" : "dark"
13+
}
14+
],
15+
"idiom" : "universal",
16+
"platform" : "ios",
17+
"size" : "1024x1024"
18+
},
19+
{
20+
"appearances" : [
21+
{
22+
"appearance" : "luminosity",
23+
"value" : "tinted"
24+
}
25+
],
26+
"idiom" : "universal",
27+
"platform" : "ios",
28+
"size" : "1024x1024"
29+
},
30+
{
31+
"idiom" : "mac",
32+
"scale" : "1x",
33+
"size" : "16x16"
34+
},
35+
{
36+
"idiom" : "mac",
37+
"scale" : "2x",
38+
"size" : "16x16"
39+
},
40+
{
41+
"idiom" : "mac",
42+
"scale" : "1x",
43+
"size" : "32x32"
44+
},
45+
{
46+
"idiom" : "mac",
47+
"scale" : "2x",
48+
"size" : "32x32"
49+
},
50+
{
51+
"idiom" : "mac",
52+
"scale" : "1x",
53+
"size" : "128x128"
54+
},
55+
{
56+
"idiom" : "mac",
57+
"scale" : "2x",
58+
"size" : "128x128"
59+
},
60+
{
61+
"idiom" : "mac",
62+
"scale" : "1x",
63+
"size" : "256x256"
64+
},
65+
{
66+
"idiom" : "mac",
67+
"scale" : "2x",
68+
"size" : "256x256"
69+
},
70+
{
71+
"idiom" : "mac",
72+
"scale" : "1x",
73+
"size" : "512x512"
74+
},
75+
{
76+
"idiom" : "mac",
77+
"scale" : "2x",
78+
"size" : "512x512"
79+
}
80+
],
81+
"info" : {
82+
"author" : "xcode",
83+
"version" : 1
84+
}
85+
}
File renamed without changes.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright © 2025 Apple Inc.
2+
3+
import MLXLLM
4+
import MLXLMCommon
5+
import SwiftUI
6+
7+
/// which model to load
8+
private let modelConfiguration = LLMRegistry.gemma3_1B_qat_4bit
9+
10+
/// instructions for the model (the system prompt)
11+
private let instructions =
12+
"""
13+
You are a friendly and helpful chatbot.
14+
"""
15+
16+
/// parameters controlling generation
17+
private let generateParameters = GenerateParameters(temperature: 0.5)
18+
19+
/// Downloads and loads the weights for the model -- we have one of these in the process
20+
@MainActor @Observable public class ModelLoader {
21+
22+
enum State {
23+
case idle
24+
case loading(Task<ModelContainer, Error>)
25+
case loaded(ModelContainer)
26+
}
27+
28+
public var progress = 0.0
29+
public var isLoaded: Bool {
30+
switch state {
31+
case .idle, .loading: false
32+
case .loaded: true
33+
}
34+
}
35+
36+
private var state = State.idle
37+
38+
public func model() async throws -> ModelContainer {
39+
switch self.state {
40+
case .idle:
41+
let task = Task {
42+
// download and report progress
43+
try await loadModelContainer(configuration: modelConfiguration) { value in
44+
Task { @MainActor in
45+
self.progress = value.fractionCompleted
46+
}
47+
}
48+
}
49+
self.state = .loading(task)
50+
let model = try await task.value
51+
52+
self.state = .loaded(model)
53+
return model
54+
55+
case .loading(let task):
56+
return try await task.value
57+
58+
case .loaded(let model):
59+
return model
60+
}
61+
}
62+
}
63+
64+
/// View model for the ChatSession
65+
@MainActor @Observable public class ChatModel {
66+
67+
private let session: ChatSession
68+
69+
/// back and forth conversation between the user and LLM
70+
public var messages = [Chat.Message]()
71+
72+
private var task: Task<Void, Error>?
73+
public var isBusy: Bool {
74+
task != nil
75+
}
76+
77+
public init(model: ModelContainer) {
78+
self.session = ChatSession(
79+
model,
80+
instructions: instructions,
81+
generateParameters: generateParameters)
82+
}
83+
84+
public func cancel() {
85+
task?.cancel()
86+
}
87+
88+
public func respond(_ message: String) {
89+
guard task == nil else { return }
90+
91+
self.messages.append(.init(role: .user, content: message))
92+
self.messages.append(.init(role: .assistant, content: "..."))
93+
let lastIndex = self.messages.count - 1
94+
95+
self.task = Task {
96+
var first = true
97+
for try await item in session.streamResponse(to: message) {
98+
if first {
99+
self.messages[lastIndex].content = item
100+
first = false
101+
} else {
102+
self.messages[lastIndex].content += item
103+
}
104+
}
105+
self.task = nil
106+
}
107+
}
108+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright © 2025 Apple Inc.
2+
3+
import MLXLMCommon
4+
import SwiftUI
5+
6+
struct ContentView: View {
7+
8+
/// provided by the application
9+
let loader: ModelLoader
10+
11+
/// once loaded this will hold the chat session
12+
@State var session: ChatModel?
13+
@State var error: String?
14+
15+
/// prompt for the LLM (text field)
16+
@State var prompt = ""
17+
18+
@FocusState var promptFocused
19+
20+
var body: some View {
21+
VStack {
22+
if let error {
23+
Text("Error: \(error)")
24+
25+
} else if !loader.isLoaded {
26+
ProgressView("Loading", value: loader.progress, total: 1)
27+
28+
} else if let session {
29+
// show the chat messages
30+
ScrollView(.vertical) {
31+
ForEach(session.messages.enumerated(), id: \.offset) { _, message in
32+
let bold = message.role == .user
33+
34+
HStack {
35+
Text(message.content)
36+
.bold(bold)
37+
Spacer()
38+
}
39+
.padding(.bottom, 4)
40+
}
41+
42+
Spacer()
43+
44+
if session.isBusy {
45+
// a stop button -- cmd-. to interrupt
46+
HStack {
47+
Button("Stop", action: { session.cancel() })
48+
.keyboardShortcut(".")
49+
Spacer()
50+
}
51+
} else {
52+
// message from the user -> LLM
53+
TextField("Prompt", text: $prompt)
54+
.onSubmit(respond)
55+
.focused($promptFocused)
56+
.onAppear {
57+
promptFocused = true
58+
}
59+
}
60+
}
61+
.defaultScrollAnchor(.bottom)
62+
}
63+
}
64+
.padding()
65+
.task {
66+
do {
67+
let model = try await loader.model()
68+
self.session = ChatModel(model: model)
69+
} catch {
70+
self.error = error.localizedDescription
71+
}
72+
}
73+
.onDisappear {
74+
self.session?.cancel()
75+
}
76+
}
77+
78+
private func respond() {
79+
session?.respond(prompt)
80+
prompt = ""
81+
}
82+
}

Applications/VLMEval/VLMEval.entitlements renamed to Applications/LLMBasic/LLMBasic.entitlements

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,5 @@
44
<dict>
55
<key>com.apple.developer.kernel.increased-memory-limit</key>
66
<true/>
7-
<key>com.apple.security.app-sandbox</key>
8-
<true/>
9-
<key>com.apple.security.device.usb</key>
10-
<true/>
11-
<key>com.apple.security.files.user-selected.read-only</key>
12-
<true/>
13-
<key>com.apple.security.network.client</key>
14-
<true/>
157
</dict>
168
</plist>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright © 2025 Apple Inc.
2+
3+
import MLX
4+
import MLXLLM
5+
import MLXLMCommon
6+
import SwiftUI
7+
8+
@main
9+
struct LLMBasicApp: App {
10+
11+
init() {
12+
Memory.cacheLimit = 20 * 1024 * 1024
13+
}
14+
15+
@State var loader = ModelLoader()
16+
17+
var body: some Scene {
18+
WindowGroup {
19+
ContentView(loader: loader)
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)