feat(debug): 添加模型导入功能并修复模拟器GPU初始化问题

- 在DebugAIRunner中添加文件导入器,支持用户选择并导入LLM模型文件夹
- 添加导入状态管理和错误提示功能
- 修复iOS模拟器环境下MLX GPU stream初始化崩溃问题,强制使用CPU模式
- 添加UniformTypeIdentifiers导入以支持文件选择功能
```
This commit is contained in:
link2026
2026-05-25 18:25:20 +08:00
parent 57536e5319
commit 9419e8158f
2 changed files with 80 additions and 0 deletions

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import MLX
import MLXLLM import MLXLLM
import MLXLMCommon import MLXLMCommon
@@ -13,6 +14,12 @@ actor LLMSession {
/// ( config.json + weights + tokenizer) /// ( config.json + weights + tokenizer)
static func load(folderURL: URL) async throws -> LLMSession { static func load(folderURL: URL) async throws -> LLMSession {
#if targetEnvironment(simulator)
// MLX iOS Simulator GPU stream Metal backend abort
// , CPU; GPU/ANE
Device.setDefault(device: .cpu)
#endif
let configuration = ModelConfiguration(directory: folderURL) let configuration = ModelConfiguration(directory: folderURL)
let container = try await LLMModelFactory.shared.loadContainer( let container = try await LLMModelFactory.shared.loadContainer(
configuration: configuration configuration: configuration

View File

@@ -1,6 +1,7 @@
#if DEBUG #if DEBUG
import SwiftUI import SwiftUI
import UIKit import UIKit
import UniformTypeIdentifiers
/// DEBUG : LLM prompt, token + /// DEBUG : LLM prompt, token +
/// Application Support ,便 /// Application Support ,便
@@ -10,6 +11,8 @@ struct DebugAIRunner: View {
@State private var rate: Double = 0 @State private var rate: Double = 0
@State private var running = false @State private var running = false
@State private var modelReady: Bool = false @State private var modelReady: Bool = false
@State private var importingModel = false
@State private var importError: String?
private var appSupportPath: String { private var appSupportPath: String {
(try? FileManager.default.url( (try? FileManager.default.url(
@@ -47,12 +50,25 @@ struct DebugAIRunner: View {
.font(.system(size: 11)) .font(.system(size: 11))
.buttonStyle(.borderless) .buttonStyle(.borderless)
Button(importingModel ? "导入中..." : "导入模型") {
importingModel = true
}
.font(.system(size: 11))
.buttonStyle(.borderless)
.disabled(importingModel)
Spacer() Spacer()
Text(modelReady ? "✓ 模型就绪" : "⚠ 模型未就绪") Text(modelReady ? "✓ 模型就绪" : "⚠ 模型未就绪")
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(modelReady ? Tj.Palette.leaf : Tj.Palette.brick) .foregroundStyle(modelReady ? Tj.Palette.leaf : Tj.Palette.brick)
} }
if let importError {
Text(importError)
.font(.system(size: 10))
.foregroundStyle(Tj.Palette.brick)
.lineLimit(3)
}
} }
.padding(10) .padding(10)
.background( .background(
@@ -95,12 +111,69 @@ struct DebugAIRunner: View {
) )
.padding(.horizontal, 16) .padding(.horizontal, 16)
.onAppear { refreshModelStatus() } .onAppear { refreshModelStatus() }
.fileImporter(
isPresented: $importingModel,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
importModelFolder(from: result)
}
} }
private func refreshModelStatus() { private func refreshModelStatus() {
modelReady = ModelStore.shared.isReady(.llm) modelReady = ModelStore.shared.isReady(.llm)
} }
private func importModelFolder(from result: Result<[URL], Error>) {
defer {
refreshModelStatus()
importingModel = false
}
do {
guard let pickedURL = try result.get().first else {
importError = "未选择模型文件夹"
return
}
let securityScoped = pickedURL.startAccessingSecurityScopedResource()
defer {
if securityScoped {
pickedURL.stopAccessingSecurityScopedResource()
}
}
let sourceURL = resolvedModelSourceURL(from: pickedURL)
guard FileManager.default.fileExists(
atPath: sourceURL.appendingPathComponent(ModelKind.llm.sentinelFilename).path
) else {
importError = "请选择包含 config.json 的 Qwen3-1.7B-4bit 文件夹"
return
}
let targetURL = ModelStore.shared.localURL(for: .llm)
let parentURL = targetURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: parentURL, withIntermediateDirectories: true)
if FileManager.default.fileExists(atPath: targetURL.path) {
try FileManager.default.removeItem(at: targetURL)
}
try FileManager.default.copyItem(at: sourceURL, to: targetURL)
importError = nil
} catch {
importError = "导入失败:\(error.localizedDescription)"
}
}
private func resolvedModelSourceURL(from pickedURL: URL) -> URL {
let nestedURL = pickedURL.appendingPathComponent(ModelKind.llm.rawValue, isDirectory: true)
if FileManager.default.fileExists(
atPath: nestedURL.appendingPathComponent(ModelKind.llm.sentinelFilename).path
) {
return nestedURL
}
return pickedURL
}
@MainActor @MainActor
private func run() async { private func run() async {
running = true running = true