@@ -62,6 +62,8 @@ struct ContentView: View {
62
62
@State private var isGenerating = false
63
63
@State private var shouldStopGenerating = false
64
64
@State private var shouldStopShowingToken = false
65
+ @State private var thinkingMode = false
66
+ @State private var showThinkingModeNotification = false
65
67
private let runnerQueue = DispatchQueue ( label: " org.pytorch.executorch.llama " )
66
68
@StateObject private var runnerHolder = RunnerHolder ( )
67
69
@StateObject private var resourceManager = ResourceManager ( )
@@ -119,107 +121,136 @@ struct ContentView: View {
119
121
120
122
var body : some View {
121
123
NavigationView {
122
- VStack {
123
- if showingSettings {
124
- VStack ( spacing: 20 ) {
125
- HStack {
126
- VStack ( spacing: 10 ) {
127
- Button ( action: { pickerType = . model } ) {
128
- Label ( modelTitle, systemImage: " doc " )
129
- . lineLimit ( 1 )
130
- . truncationMode ( . middle)
131
- . frame ( maxWidth: 300 , alignment: . leading)
132
- }
133
- Button ( action: { pickerType = . tokenizer } ) {
134
- Label ( tokenizerTitle, systemImage: " doc " )
135
- . lineLimit ( 1 )
136
- . truncationMode ( . middle)
137
- . frame ( maxWidth: 300 , alignment: . leading)
124
+ ZStack {
125
+ VStack {
126
+ if showingSettings {
127
+ VStack ( spacing: 20 ) {
128
+ HStack {
129
+ VStack ( spacing: 10 ) {
130
+ Button ( action: { pickerType = . model } ) {
131
+ Label ( modelTitle, systemImage: " doc " )
132
+ . lineLimit ( 1 )
133
+ . truncationMode ( . middle)
134
+ . frame ( maxWidth: 300 , alignment: . leading)
135
+ }
136
+ Button ( action: { pickerType = . tokenizer } ) {
137
+ Label ( tokenizerTitle, systemImage: " doc " )
138
+ . lineLimit ( 1 )
139
+ . truncationMode ( . middle)
140
+ . frame ( maxWidth: 300 , alignment: . leading)
141
+ }
138
142
}
143
+ . padding ( )
144
+ . background ( Color . gray. opacity ( 0.1 ) )
145
+ . cornerRadius ( 10 )
146
+ . fixedSize ( horizontal: true , vertical: false )
147
+ Spacer ( )
139
148
}
140
149
. padding ( )
141
- . background ( Color . gray. opacity ( 0.1 ) )
142
- . cornerRadius ( 10 )
143
- . fixedSize ( horizontal: true , vertical: false )
144
- Spacer ( )
145
150
}
146
- . padding ( )
147
151
}
148
- }
149
152
150
- MessageListView ( messages: $messages)
151
- . simultaneousGesture (
152
- DragGesture ( ) . onChanged { value in
153
- if value. translation. height > 10 {
154
- hideKeyboard ( )
153
+ MessageListView ( messages: $messages)
154
+ . simultaneousGesture (
155
+ DragGesture ( ) . onChanged { value in
156
+ if value. translation. height > 10 {
157
+ hideKeyboard ( )
158
+ }
159
+ showingSettings = false
160
+ textFieldFocused = false
155
161
}
162
+ )
163
+ . onTapGesture {
156
164
showingSettings = false
157
165
textFieldFocused = false
158
166
}
159
- )
160
- . onTapGesture {
161
- showingSettings = false
162
- textFieldFocused = false
163
- }
164
167
165
- HStack {
166
- Button ( action: {
167
- imagePickerSourceType = . photoLibrary
168
- isImagePickerPresented = true
169
- } ) {
170
- Image ( systemName: " photo.on.rectangle " )
171
- . resizable ( )
172
- . scaledToFit ( )
173
- . frame ( width: 24 , height: 24 )
174
- }
175
- . background ( Color . clear)
176
- . cornerRadius ( 8 )
177
-
178
- Button ( action: {
179
- if UIImagePickerController . isSourceTypeAvailable ( . camera) {
180
- imagePickerSourceType = . camera
168
+ HStack {
169
+ Button ( action: {
170
+ imagePickerSourceType = . photoLibrary
181
171
isImagePickerPresented = true
182
- } else {
183
- print ( " Camera not available " )
172
+ } ) {
173
+ Image ( systemName: " photo.on.rectangle " )
174
+ . resizable ( )
175
+ . scaledToFit ( )
176
+ . frame ( width: 24 , height: 24 )
184
177
}
185
- } ) {
186
- Image ( systemName: " camera " )
187
- . resizable ( )
188
- . scaledToFit ( )
189
- . frame ( width: 24 , height: 24 )
190
- }
191
- . background ( Color . clear)
192
- . cornerRadius ( 8 )
178
+ . background ( Color . clear)
179
+ . cornerRadius ( 8 )
193
180
194
- TextField ( placeholder, text: $prompt, axis: . vertical)
195
- . padding ( 8 )
196
- . background ( Color . gray. opacity ( 0.1 ) )
197
- . cornerRadius ( 20 )
198
- . lineLimit ( 1 ... 10 )
199
- . overlay (
200
- RoundedRectangle ( cornerRadius: 20 )
201
- . stroke ( isInputEnabled ? Color . blue : Color . gray, lineWidth: 1 )
202
- )
203
- . disabled ( !isInputEnabled)
204
- . focused ( $textFieldFocused)
205
- . onAppear { textFieldFocused = false }
206
- . onTapGesture {
207
- showingSettings = false
181
+ Button ( action: {
182
+ if UIImagePickerController . isSourceTypeAvailable ( . camera) {
183
+ imagePickerSourceType = . camera
184
+ isImagePickerPresented = true
185
+ } else {
186
+ print ( " Camera not available " )
187
+ }
188
+ } ) {
189
+ Image ( systemName: " camera " )
190
+ . resizable ( )
191
+ . scaledToFit ( )
192
+ . frame ( width: 24 , height: 24 )
193
+ }
194
+ . background ( Color . clear)
195
+ . cornerRadius ( 8 )
196
+
197
+ if resourceManager. isModelValid && ModelType . fromPath ( resourceManager. modelPath) == . qwen3 {
198
+ Button ( action: {
199
+ thinkingMode. toggle ( )
200
+ showThinkingModeNotification = true
201
+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + 3 ) {
202
+ showThinkingModeNotification = false
203
+ }
204
+ } ) {
205
+ Image ( systemName: " brain " )
206
+ . resizable ( )
207
+ . scaledToFit ( )
208
+ . frame ( width: 24 , height: 24 )
209
+ . foregroundColor ( thinkingMode ? . blue : . gray)
210
+ }
211
+ . background ( Color . clear)
212
+ . cornerRadius ( 8 )
208
213
}
209
214
210
- Button ( action: isGenerating ? stop : generate) {
211
- Image ( systemName: isGenerating ? " stop.circle " : " arrowshape.up.circle.fill " )
212
- . resizable ( )
213
- . aspectRatio ( contentMode: . fit)
214
- . frame ( height: 28 )
215
+ TextField ( placeholder, text: $prompt, axis: . vertical)
216
+ . padding ( 8 )
217
+ . background ( Color . gray. opacity ( 0.1 ) )
218
+ . cornerRadius ( 20 )
219
+ . lineLimit ( 1 ... 10 )
220
+ . overlay (
221
+ RoundedRectangle ( cornerRadius: 20 )
222
+ . stroke ( isInputEnabled ? Color . blue : Color . gray, lineWidth: 1 )
223
+ )
224
+ . disabled ( !isInputEnabled)
225
+ . focused ( $textFieldFocused)
226
+ . onAppear { textFieldFocused = false }
227
+ . onTapGesture {
228
+ showingSettings = false
229
+ }
230
+
231
+ Button ( action: isGenerating ? stop : generate) {
232
+ Image ( systemName: isGenerating ? " stop.circle " : " arrowshape.up.circle.fill " )
233
+ . resizable ( )
234
+ . aspectRatio ( contentMode: . fit)
235
+ . frame ( height: 28 )
236
+ }
237
+ . disabled ( isGenerating ? shouldStopGenerating : ( !isInputEnabled || prompt. isEmpty) )
215
238
}
216
- . disabled ( isGenerating ? shouldStopGenerating : ( !isInputEnabled || prompt . isEmpty ) )
239
+ . padding ( [ . leading , . trailing , . bottom ] , 10 )
217
240
}
218
- . padding ( [ . leading, . trailing, . bottom] , 10 )
219
241
. sheet ( isPresented: $isImagePickerPresented, onDismiss: addSelectedImageMessage) {
220
242
ImagePicker ( selectedImage: $selectedImage, sourceType: imagePickerSourceType)
221
243
. id ( imagePickerSourceType. rawValue)
222
244
}
245
+
246
+ if showThinkingModeNotification {
247
+ Text ( thinkingMode ? " Thinking mode enabled " : " Thinking mode disabled " )
248
+ . padding ( 8 )
249
+ . background ( Color ( UIColor . secondarySystemBackground) )
250
+ . cornerRadius ( 8 )
251
+ . transition ( . opacity)
252
+ . animation ( . easeInOut( duration: 0.2 ) , value: showThinkingModeNotification)
253
+ }
223
254
}
224
255
. navigationBarTitle ( title, displayMode: . inline)
225
256
. navigationBarItems (
@@ -435,7 +466,10 @@ struct ContentView: View {
435
466
let prompt : String
436
467
switch modelType {
437
468
case . qwen3:
438
- prompt = String ( format: Constants . qwen3PromptTemplate, text)
469
+ let basePrompt = String ( format: Constants . qwen3PromptTemplate, text)
470
+ // If thinking mode is enabled for Qwen, don't skip the <think></think> special tokens
471
+ // and have them be generated.
472
+ prompt = thinkingMode ? basePrompt. replacingOccurrences ( of: " <think> \n \n </think> \n \n \n " , with: " " ) : basePrompt
439
473
case . llama:
440
474
prompt = String ( format: Constants . llama3PromptTemplate, text)
441
475
case . llava:
@@ -445,12 +479,45 @@ struct ContentView: View {
445
479
try runnerHolder. runner? . generate ( prompt, sequenceLength: seq_len) { token in
446
480
447
481
if token != prompt {
448
- // hack to fix the issue that extension/llm/runner/text_token_generator.h
449
- // keeps generating after <|eot_id|>
450
482
if token == " <|eot_id|> " {
483
+ // hack to fix the issue that extension/llm/runner/text_token_generator.h
484
+ // keeps generating after <|eot_id|>
451
485
shouldStopShowingToken = true
486
+ } else if token == " <|im_end|> " {
487
+ // Qwen3 specific token.
488
+ // Skip.
489
+ } else if token == " <think> " {
490
+ // Qwen3 specific token.
491
+ let textToFlush = tokens. joined ( )
492
+ let flushedTokenCount = tokens. count
493
+ tokens = [ ]
494
+ DispatchQueue . main. async {
495
+ var message = messages. removeLast ( )
496
+ message. text += textToFlush
497
+ message. text += message. text. isEmpty ? " Thinking... \n \n " : " \n \n Thinking... \n \n "
498
+ message. format = . italic
499
+ message. tokenCount += flushedTokenCount + 1 // + 1 for the start thinking token.
500
+ message. dateUpdated = Date ( )
501
+ messages. append ( message)
502
+ }
503
+ } else if token == " </think> " {
504
+ // Qwen3 specific token.
505
+ let textToFlush = tokens. joined ( )
506
+ let flushedTokenCount = tokens. count
507
+ tokens = [ ]
508
+ DispatchQueue . main. async {
509
+ var message = messages. removeLast ( )
510
+ message. text += textToFlush
511
+ message. text += " \n \n Finished thinking. \n \n "
512
+ message. format = . italic
513
+ message. tokenCount += flushedTokenCount + 1 // + 1 for the end thinking token.
514
+ message. dateUpdated = Date ( )
515
+ messages. append ( message)
516
+ }
452
517
} else {
453
518
tokens. append ( token. trimmingCharacters ( in: . newlines) )
519
+ // Flush tokens in groups of 3 so that it's closer to whole words being generated
520
+ // rather than parts of words (tokens).
454
521
if tokens. count > 2 {
455
522
let text = tokens. joined ( )
456
523
let count = tokens. count
0 commit comments