Skip to content

Move demo app to OSS. #2355

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions examples/demo-apps/apple_ios/LLaMA/LLaMA/Application/App.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.

import SwiftUI

@main
struct App: SwiftUI.App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.

import SwiftUI
import UniformTypeIdentifiers

import LLaMARunner

class RunnerHolder: ObservableObject {
var runner: Runner?
}

struct ContentView: View {
@State private var prompt = ""
@State private var messages: [Message] = []
@State private var showingLogs = false
@State private var pickerType: PickerType?
@State private var isGenerating = false
@State private var shouldStopGenerating = false
private let runnerQueue = DispatchQueue(label: "org.pytorch.executorch.llama")
@StateObject private var runnerHolder = RunnerHolder()
@StateObject private var resourceManager = ResourceManager()
@StateObject private var resourceMonitor = ResourceMonitor()
@StateObject private var logManager = LogManager()

enum PickerType {
case model
case tokenizer
}

private var placeholder: String {
resourceManager.isModelValid ? resourceManager.isTokenizerValid ? "Prompt..." : "Select Tokenizer..." : "Select Model..."
}

private var title: String {
resourceManager.isModelValid ? resourceManager.isTokenizerValid ? resourceManager.modelName : "Select Tokenizer..." : "Select Model..."
}

private var modelTitle: String {
resourceManager.isModelValid ? resourceManager.modelName : "Select Model..."
}

private var tokenizerTitle: String {
resourceManager.isTokenizerValid ? resourceManager.tokenizerName : "Select Tokenizer..."
}

private var isInputEnabled: Bool { resourceManager.isModelValid && resourceManager.isTokenizerValid }

var body: some View {
NavigationView {
VStack {
MessageListView(messages: $messages)
.gesture(
DragGesture().onChanged { value in
if value.translation.height > 10 {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
)
HStack {
Menu {
Section(header: Text("Model")) {
Button(action: { pickerType = .model }) {
Label(modelTitle, systemImage: "doc")
}
}
Section(header: Text("Tokenizer")) {
Button(action: { pickerType = .tokenizer }) {
Label(tokenizerTitle, systemImage: "doc")
}
}
} label: {
Image(systemName: "ellipsis.circle")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 28)
}
.disabled(isGenerating)

TextField(placeholder, text: $prompt, axis: .vertical)
.padding(8)
.background(Color.gray.opacity(0.1))
.cornerRadius(20)
.lineLimit(1...10)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(isInputEnabled ? Color.blue : Color.gray, lineWidth: 1)
)
.disabled(!isInputEnabled)

Button(action: isGenerating ? stop : generate) {
Image(systemName: isGenerating ? "stop.circle" : "arrowshape.up.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 28)
}
.disabled(isGenerating ? shouldStopGenerating : (!isInputEnabled || prompt.isEmpty))
}
.padding([.leading, .trailing, .bottom], 10)
}
.navigationBarTitle(title, displayMode: .inline)
.navigationBarItems(trailing:
HStack {
Menu {
Section(header: Text("Memory")) {
Text("Used: \(resourceMonitor.usedMemory) Mb")
Text("Available: \(resourceMonitor.availableMemory) Mb")
}
} label: {
Text("\(resourceMonitor.usedMemory) Mb")
}
.onAppear {
resourceMonitor.start()
}
.onDisappear {
resourceMonitor.stop()
}
Button(action: { showingLogs = true }) {
Image(systemName: "list.bullet.rectangle")
}
}
)
.sheet(isPresented: $showingLogs) {
NavigationView {
LogView(logManager: logManager)
}
}
.fileImporter(
isPresented: Binding<Bool>(
get: { pickerType != nil },
set: { if !$0 { pickerType = nil } }
),
allowedContentTypes: allowedContentTypes(),
allowsMultipleSelection: false
) { [pickerType] result in
handleFileImportResult(pickerType, result)
}
.onAppear {
do {
try resourceManager.createDirectoriesIfNeeded()
} catch {
withAnimation {
messages.append(Message(type: .info, text: "Error creating content directories: \(error.localizedDescription)"))
}
}
}
}
}

private func generate() {
guard !prompt.isEmpty else { return }
isGenerating = true
shouldStopGenerating = false
let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
let seq_len = 128
prompt = ""
let modelPath = resourceManager.modelPath
let tokenizerPath = resourceManager.tokenizerPath

messages.append(Message(text: text))
messages.append(Message(type: .generated))

runnerQueue.async {
defer {
DispatchQueue.main.async {
isGenerating = false
}
}
runnerHolder.runner = runnerHolder.runner ?? Runner(modelPath: modelPath, tokenizerPath: tokenizerPath)
guard !shouldStopGenerating else { return }
if let runner = runnerHolder.runner, !runner.isloaded() {
var error: Error?
let startLoadTime = Date()
do {
try runner.load()
} catch let loadError {
error = loadError
}
let loadTime = Date().timeIntervalSince(startLoadTime)
DispatchQueue.main.async {
withAnimation {
var message = messages.removeLast()
message.type = .info
if let error {
message.text = "Model loading failed: error \((error as NSError).code)"
} else {
message.text = "Model loaded in \(String(format: "%.1f", loadTime)) s"
}
messages.append(message)
if error == nil {
messages.append(Message(type: .generated))
}
}
}
if error != nil {
return
}
}
guard !shouldStopGenerating else {
DispatchQueue.main.async {
withAnimation {
_ = messages.removeLast()
}
}
return
}
do {
try runnerHolder.runner?.generate(text, sequenceLength: seq_len) { token in

DispatchQueue.main.async {
withAnimation {
var message = messages.removeLast()
message.text += token
message.tokenCount += 1
message.dateUpdated = Date()
messages.append(message)
}
}
if shouldStopGenerating {
runnerHolder.runner?.stop()
}
}
} catch {
DispatchQueue.main.async {
withAnimation {
var message = messages.removeLast()
message.type = .info
message.text = "Text generation failed: error \((error as NSError).code)"
messages.append(message)
}
}
}
}
}

private func stop() {
shouldStopGenerating = true
}

private func allowedContentTypes() -> [UTType] {
guard let pickerType else { return [] }
switch pickerType {
case .model:
return [UTType(filenameExtension: "pte")].compactMap { $0 }
case .tokenizer:
return [UTType(filenameExtension: "bin")].compactMap { $0 }
}
}

private func handleFileImportResult(_ pickerType: PickerType?, _ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first, let pickerType else {
withAnimation {
messages.append(Message(type: .info, text: "Failed to select a file"))
}
return
}
runnerQueue.async {
runnerHolder.runner = nil
}
switch pickerType {
case .model:
resourceManager.modelPath = url.path
case .tokenizer:
resourceManager.tokenizerPath = url.path
}
case .failure(let error):
withAnimation {
messages.append(Message(type: .info, text: "Failed to select a file: \(error.localizedDescription)"))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.

import SwiftUI

import ExecuTorch

struct LogEntry: Identifiable, Codable {
let id: UUID
let level: Int
let timestamp: TimeInterval
let filename: String
let line: UInt
let message: String
}

class LogManager: ObservableObject, LogSink {
@AppStorage("logs") private var data = Data()

@Published var logs: [LogEntry] = [] {
didSet {
data = (try? JSONEncoder().encode(logs)) ?? Data()
}
}

init() {
logs = (try? JSONDecoder().decode([LogEntry].self, from: data)) ?? []
Log.shared.add(sink: self)
}

deinit {
Log.shared.remove(sink: self)
}

func log(level: LogLevel, timestamp: TimeInterval, filename: String, line: UInt, message: String) {
let log = LogEntry(id: UUID(), level: level.rawValue, timestamp: timestamp, filename: filename, line: line, message: message)

DispatchQueue.main.sync {
self.logs.append(log)
}
}

func clear() {
logs.removeAll()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.

import SwiftUI

import ExecuTorch

struct LogView: View {
@ObservedObject var logManager: LogManager

var body: some View {
ScrollView {
VStack(alignment: .leading) {
ForEach(logManager.logs) { log in
Text("\(format(timestamp: log.timestamp)) \(log.filename):\(log.line)")
.padding(.top)
.foregroundColor(.secondary)
.textSelection(.enabled)
Text(log.message)
.padding(.bottom)
.foregroundColor(color(for: log.level))
.textSelection(.enabled)
}
}
}
.padding()
.defaultScrollAnchor(.bottom)
.navigationBarTitle("Logs", displayMode: .inline)
.navigationBarItems(trailing:
Button(action: { logManager.clear() }) {
Image(systemName: "trash")
}
)
}

private func format(timestamp: TimeInterval) -> String {
let totalSeconds = Int(timestamp)
let hours = (totalSeconds / 3600) % 24
let minutes = (totalSeconds / 60) % 60
let seconds = totalSeconds % 60
let microseconds = Int((timestamp - Double(totalSeconds)) * 1000000)
return String(format: "%02d:%02d:%02d.%06d", hours, minutes, seconds, microseconds)
}

private func color(for level: Int) -> Color {
switch LogLevel(rawValue: level) {
case .debug:
return .blue
case .info:
return .primary
case .error:
return .red
case .fatal:
return .purple
default:
return .secondary
}
}
}
Loading