diff --git a/体己/AI/LLMSession.swift b/体己/AI/LLMSession.swift index 579a321..1109951 100644 --- a/体己/AI/LLMSession.swift +++ b/体己/AI/LLMSession.swift @@ -1,4 +1,5 @@ import Foundation +import MLX import MLXLLM import MLXLMCommon @@ -13,6 +14,12 @@ actor LLMSession { /// 从本地目录加载模型(包含 config.json + weights + tokenizer)。 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 container = try await LLMModelFactory.shared.loadContainer( configuration: configuration diff --git a/体己/Debug/DebugAIRunner.swift b/体己/Debug/DebugAIRunner.swift index 186775c..82a4fbb 100644 --- a/体己/Debug/DebugAIRunner.swift +++ b/体己/Debug/DebugAIRunner.swift @@ -1,6 +1,7 @@ #if DEBUG import SwiftUI import UIKit +import UniformTypeIdentifiers /// DEBUG 自检:加载 LLM 并跑一段 prompt,流式显示 token + 速率。 /// 同时显示沙盒 Application Support 路径,方便把模型拷进去。 @@ -10,6 +11,8 @@ struct DebugAIRunner: View { @State private var rate: Double = 0 @State private var running = false @State private var modelReady: Bool = false + @State private var importingModel = false + @State private var importError: String? private var appSupportPath: String { (try? FileManager.default.url( @@ -47,12 +50,25 @@ struct DebugAIRunner: View { .font(.system(size: 11)) .buttonStyle(.borderless) + Button(importingModel ? "导入中..." : "导入模型") { + importingModel = true + } + .font(.system(size: 11)) + .buttonStyle(.borderless) + .disabled(importingModel) + Spacer() Text(modelReady ? "✓ 模型就绪" : "⚠ 模型未就绪") .font(.system(size: 11, weight: .medium)) .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) .background( @@ -95,12 +111,69 @@ struct DebugAIRunner: View { ) .padding(.horizontal, 16) .onAppear { refreshModelStatus() } + .fileImporter( + isPresented: $importingModel, + allowedContentTypes: [.folder], + allowsMultipleSelection: false + ) { result in + importModelFolder(from: result) + } } private func refreshModelStatus() { 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 private func run() async { running = true