mirror of https://github.com/alibaba/MNN.git
Merge branch 'feature_0901'
# Conflicts: # apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift # apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift # apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift # apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift # apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadConfiguration.swift # apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift # apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift # apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/String+Extension.swift
This commit is contained in:
commit
eda46aca14
|
@ -8,9 +8,10 @@
|
|||
|
||||
/* Begin PBXBuildFile section */
|
||||
3E301C692D5C84730045E5E1 /* MNN.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E301C682D5C84730045E5E1 /* MNN.framework */; };
|
||||
3E6301D42E6A8A470004EC63 /* ExyteChat in Frameworks */ = {isa = PBXBuildFile; productRef = 3E6301D32E6A8A470004EC63 /* ExyteChat */; };
|
||||
3E94DFF12D37DBA900BE39A7 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 3E94DFF02D37DBA900BE39A7 /* SQLite */; };
|
||||
3EC8DF852E4DCF5600861131 /* ExyteChat in Frameworks */ = {isa = PBXBuildFile; productRef = 3EC8DF842E4DCF5600861131 /* ExyteChat */; };
|
||||
3EC8DF872E4DCF5800861131 /* Transformers in Frameworks */ = {isa = PBXBuildFile; productRef = 3EC8DF862E4DCF5800861131 /* Transformers */; };
|
||||
3EDA70222E605BE700B17E48 /* ExyteChat in Frameworks */ = {isa = PBXBuildFile; productRef = 3EDA70212E605BE700B17E48 /* ExyteChat */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
@ -84,10 +85,11 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3EDA70222E605BE700B17E48 /* ExyteChat in Frameworks */,
|
||||
3E301C692D5C84730045E5E1 /* MNN.framework in Frameworks */,
|
||||
3E6301D42E6A8A470004EC63 /* ExyteChat in Frameworks */,
|
||||
3E94DFF12D37DBA900BE39A7 /* SQLite in Frameworks */,
|
||||
3EC8DF872E4DCF5800861131 /* Transformers in Frameworks */,
|
||||
3EC8DF852E4DCF5600861131 /* ExyteChat in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -158,8 +160,9 @@
|
|||
name = MNNLLMiOS;
|
||||
packageProductDependencies = (
|
||||
3E94DFF02D37DBA900BE39A7 /* SQLite */,
|
||||
3EC8DF842E4DCF5600861131 /* ExyteChat */,
|
||||
3EC8DF862E4DCF5800861131 /* Transformers */,
|
||||
3EDA70212E605BE700B17E48 /* ExyteChat */,
|
||||
3E6301D32E6A8A470004EC63 /* ExyteChat */,
|
||||
);
|
||||
productName = MNNLLMiOS;
|
||||
productReference = 3E8591FE2D1D45070067B46F /* MNNLLMiOS.app */;
|
||||
|
@ -246,8 +249,8 @@
|
|||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
3E94DF8A2D37D8FF00BE39A7 /* XCRemoteSwiftPackageReference "SQLite" */,
|
||||
3EC8DF822E4DCDF500861131 /* XCRemoteSwiftPackageReference "Chat" */,
|
||||
3EC8DF832E4DCF4A00861131 /* XCRemoteSwiftPackageReference "swift-transformers" */,
|
||||
3E6301D22E6A8A470004EC63 /* XCRemoteSwiftPackageReference "Chat" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 3E8591FF2D1D45070067B46F /* Products */;
|
||||
|
@ -357,7 +360,7 @@
|
|||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
|
@ -451,6 +454,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = MNNLLMiOS/MNNLLMiOS.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MNNLLMiOS/Preview Content\"";
|
||||
|
@ -465,7 +469,7 @@
|
|||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = MNNLLMiOS/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "MNN Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need access your camera to capture image or video";
|
||||
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "This app needs to access your documents to save downloaded models";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "This app needs to access your local network to download models";
|
||||
|
@ -479,8 +483,7 @@
|
|||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
|
@ -488,6 +491,7 @@
|
|||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.taobao.mnnchat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
@ -506,6 +510,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = MNNLLMiOS/MNNLLMiOS.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MNNLLMiOS/Preview Content\"";
|
||||
|
@ -520,7 +525,7 @@
|
|||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = MNNLLMiOS/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "MNN Chat";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need access your camera to capture image or video";
|
||||
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "This app needs to access your documents to save downloaded models";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "This app needs to access your local network to download models";
|
||||
|
@ -534,15 +539,15 @@
|
|||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 14;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.taobao.mnnchat;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
@ -562,6 +567,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6QW92DF7RL;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
|
@ -686,6 +692,14 @@
|
|||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
3E6301D22E6A8A470004EC63 /* XCRemoteSwiftPackageReference "Chat" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Yogayu/Chat.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.0.1;
|
||||
};
|
||||
};
|
||||
3E94DF8A2D37D8FF00BE39A7 /* XCRemoteSwiftPackageReference "SQLite" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/stephencelis/SQLite.swift";
|
||||
|
@ -694,14 +708,6 @@
|
|||
minimumVersion = 0.15.3;
|
||||
};
|
||||
};
|
||||
3EC8DF822E4DCDF500861131 /* XCRemoteSwiftPackageReference "Chat" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Yogayu/Chat.git";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
3EC8DF832E4DCF4A00861131 /* XCRemoteSwiftPackageReference "swift-transformers" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/huggingface/swift-transformers/";
|
||||
|
@ -713,21 +719,25 @@
|
|||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
3E6301D32E6A8A470004EC63 /* ExyteChat */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3E6301D22E6A8A470004EC63 /* XCRemoteSwiftPackageReference "Chat" */;
|
||||
productName = ExyteChat;
|
||||
};
|
||||
3E94DFF02D37DBA900BE39A7 /* SQLite */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3E94DF8A2D37D8FF00BE39A7 /* XCRemoteSwiftPackageReference "SQLite" */;
|
||||
productName = SQLite;
|
||||
};
|
||||
3EC8DF842E4DCF5600861131 /* ExyteChat */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3EC8DF822E4DCDF500861131 /* XCRemoteSwiftPackageReference "Chat" */;
|
||||
productName = ExyteChat;
|
||||
};
|
||||
3EC8DF862E4DCF5800861131 /* Transformers */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3EC8DF832E4DCF4A00861131 /* XCRemoteSwiftPackageReference "swift-transformers" */;
|
||||
productName = Transformers;
|
||||
};
|
||||
3EDA70212E605BE700B17E48 /* ExyteChat */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ExyteChat;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 3E8591F62D1D45070067B46F /* Project object */;
|
||||
|
|
12
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/Contents.json
vendored
Normal file
12
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "benchmark.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/benchmark.pdf
vendored
Normal file
BIN
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/benchmark.pdf
vendored
Normal file
Binary file not shown.
12
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/Contents.json
vendored
Normal file
12
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "benchmarkFill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/benchmarkFill.pdf
vendored
Normal file
BIN
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/benchmarkFill.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "home.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
12
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/Contents.json
vendored
Normal file
12
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "homeFill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/homeFill.pdf
vendored
Normal file
BIN
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/homeFill.pdf
vendored
Normal file
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "market.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
12
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/Contents.json
vendored
Normal file
12
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "marketFill.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/marketFill.pdf
vendored
Normal file
BIN
apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/marketFill.pdf
vendored
Normal file
Binary file not shown.
|
@ -8,7 +8,6 @@
|
|||
|
||||
import UIKit
|
||||
import ExyteChat
|
||||
import ExyteMediaPicker
|
||||
|
||||
final class LLMChatData {
|
||||
var assistant: LLMChatUser
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
//
|
||||
|
||||
import ExyteChat
|
||||
import ExyteMediaPicker
|
||||
|
||||
actor LLMState {
|
||||
private var isProcessing: Bool = false
|
||||
|
|
|
@ -9,7 +9,6 @@ import SwiftUI
|
|||
import AVFoundation
|
||||
|
||||
import ExyteChat
|
||||
import ExyteMediaPicker
|
||||
|
||||
final class LLMChatViewModel: ObservableObject {
|
||||
|
||||
|
@ -48,12 +47,12 @@ final class LLMChatViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
var chatCover: URL? {
|
||||
interactor.otherSenders.count == 1 ? interactor.otherSenders.first!.avatar : nil
|
||||
interactor.otherSenders.count == 1 ? interactor.otherSenders.first?.avatar : nil
|
||||
}
|
||||
|
||||
|
||||
private let interactor: LLMChatInteractor
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
|
||||
var modelInfo: ModelInfo
|
||||
var history: ChatHistory?
|
||||
private var historyId: String
|
||||
|
@ -65,6 +64,7 @@ final class LLMChatViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
init(modelInfo: ModelInfo, history: ChatHistory? = nil) {
|
||||
print("yxy:: LLMChat View Model init")
|
||||
self.modelInfo = modelInfo
|
||||
self.history = history
|
||||
self.historyId = history?.id ?? UUID().uuidString
|
||||
|
@ -77,7 +77,6 @@ final class LLMChatViewModel: ObservableObject {
|
|||
|
||||
// Check if model supports thinking mode
|
||||
self.supportsThinkingMode = ModelUtils.isSupportThinkingSwitch(modelInfo.tags, modelName: modelInfo.modelName)
|
||||
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
@ -104,6 +103,7 @@ final class LLMChatViewModel: ObservableObject {
|
|||
|
||||
func setupLLM(modelPath: String) {
|
||||
Task { @MainActor in
|
||||
self.isModelLoaded = false
|
||||
self.send(draft: DraftMessage(
|
||||
text: NSLocalizedString("ModelLoadingText", comment: ""),
|
||||
thinkText: "",
|
||||
|
@ -113,21 +113,21 @@ final class LLMChatViewModel: ObservableObject {
|
|||
createdAt: Date()
|
||||
), userType: .system)
|
||||
}
|
||||
|
||||
|
||||
if modelInfo.modelName.lowercased().contains("diffusion") {
|
||||
diffusion = DiffusionSession(modelPath: modelPath, completion: { [weak self] success in
|
||||
Task { @MainActor in
|
||||
print("Diffusion Model \(success)")
|
||||
self?.isModelLoaded = success
|
||||
self?.sendModelLoadStatus(success: success)
|
||||
self?.isModelLoaded = success
|
||||
}
|
||||
})
|
||||
} else {
|
||||
llm = LLMInferenceEngineWrapper(modelPath: modelPath) { [weak self] success in
|
||||
Task { @MainActor in
|
||||
self?.isModelLoaded = success
|
||||
self?.sendModelLoadStatus(success: success)
|
||||
self?.processHistoryMessages()
|
||||
self?.isModelLoaded = success
|
||||
|
||||
// Configure thinking mode after model is loaded
|
||||
if success {
|
||||
|
@ -155,7 +155,7 @@ final class LLMChatViewModel: ObservableObject {
|
|||
let modelLoadSuccessText = NSLocalizedString("ModelLoadingSuccessText", comment: "")
|
||||
let modelLoadFailText = NSLocalizedString("ModelLoadingFailText", comment: "")
|
||||
let loadResult = success ? modelLoadSuccessText : modelLoadFailText
|
||||
|
||||
|
||||
self.send(draft: DraftMessage(
|
||||
text: loadResult,
|
||||
thinkText: "",
|
||||
|
@ -196,6 +196,9 @@ final class LLMChatViewModel: ObservableObject {
|
|||
NotificationCenter.default.post(name: .dismissKeyboard, object: nil)
|
||||
|
||||
self.send(draft: draft, userType: .user)
|
||||
|
||||
recordModelUsage()
|
||||
|
||||
if isModelLoaded {
|
||||
if modelInfo.modelName.lowercased().contains("diffusion") {
|
||||
self.getDiffusionResponse(draft: draft)
|
||||
|
@ -214,7 +217,7 @@ final class LLMChatViewModel: ObservableObject {
|
|||
Task {
|
||||
|
||||
let tempImagePath = FileOperationManager.shared.generateTempImagePath().path
|
||||
|
||||
|
||||
var lastProcess:Int32 = 0
|
||||
|
||||
self.send(draft: DraftMessage(text: "Start Generating Image...", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .assistant)
|
||||
|
@ -223,16 +226,15 @@ final class LLMChatViewModel: ObservableObject {
|
|||
let userIterations = self.modelConfigManager.readIterations()
|
||||
let userSeed = self.modelConfigManager.readSeed()
|
||||
|
||||
// 使用用户设置的参数调用新方法
|
||||
diffusion?.run(withPrompt: draft.text,
|
||||
imagePath: tempImagePath,
|
||||
iterations: Int32(userIterations),
|
||||
seed: Int32(userSeed),
|
||||
progressCallback: { [weak self] progress in
|
||||
diffusion?.run(withPrompt: draft.text,
|
||||
imagePath: tempImagePath,
|
||||
iterations: Int32(userIterations),
|
||||
seed: Int32(userSeed),
|
||||
progressCallback: { [weak self] progress in
|
||||
guard let self = self else { return }
|
||||
if progress == 100 {
|
||||
self.send(draft: DraftMessage(text: "Image generated successfully!", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .system)
|
||||
self.interactor.sendImage(imageURL: URL(string: "file://" + tempImagePath)!)
|
||||
self.interactor.sendImage(imageURL: URL(fileURLWithPath: tempImagePath))
|
||||
} else if ((progress - lastProcess) > 20) {
|
||||
lastProcess = progress
|
||||
self.send(draft: DraftMessage(text: "Generating Image \(progress)%", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .system)
|
||||
|
@ -244,7 +246,7 @@ final class LLMChatViewModel: ObservableObject {
|
|||
func getLLMRespsonse(draft: DraftMessage) {
|
||||
Task {
|
||||
await llmState.setProcessing(true)
|
||||
await MainActor.run {
|
||||
await MainActor.run {
|
||||
self.isProcessing = true
|
||||
let emptyMessage = DraftMessage(
|
||||
text: "",
|
||||
|
@ -268,7 +270,7 @@ final class LLMChatViewModel: ObservableObject {
|
|||
guard media.type == .image, let url = await media.getURL() else {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
let fileName = url.lastPathComponent
|
||||
|
||||
if let processedUrl = FileOperationManager.shared.processImageFile(from: url, fileName: fileName) {
|
||||
|
@ -277,9 +279,9 @@ final class LLMChatViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
if let audio = draft.recording, let path = audio.url {
|
||||
// if let wavFile = await convertACCToWAV(accFileUrl: path) {
|
||||
// if let wavFile = await convertACCToWAV(accFileUrl: path) {
|
||||
content = "<audio>\(path.path)</audio>" + content
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
let convertedContent = self.convertDeepSeekMutliChat(content: content)
|
||||
|
@ -317,7 +319,7 @@ final class LLMChatViewModel: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
Task {
|
||||
await UIUpdateOptimizer.shared.addUpdate(output) { [weak self] output in
|
||||
guard let self = self else { return }
|
||||
self.send(draft: DraftMessage(
|
||||
|
@ -390,16 +392,20 @@ final class LLMChatViewModel: ObservableObject {
|
|||
interactor.connect()
|
||||
|
||||
self.setupLLM(modelPath: self.modelInfo.localPath)
|
||||
|
||||
recordModelUsage()
|
||||
}
|
||||
|
||||
|
||||
func onStop() {
|
||||
|
||||
recordModelUsage()
|
||||
|
||||
ChatHistoryManager.shared.saveChat(
|
||||
historyId: historyId,
|
||||
modelInfo: modelInfo,
|
||||
messages: messages
|
||||
)
|
||||
|
||||
|
||||
subscriptions.removeAll()
|
||||
|
||||
interactor.disconnect()
|
||||
|
@ -413,10 +419,20 @@ final class LLMChatViewModel: ObservableObject {
|
|||
FileOperationManager.shared.cleanModelTempFolder(modelPath: modelInfo.localPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func loadMoreMessage(before message: Message) {
|
||||
interactor.loadNextPage()
|
||||
.sink { _ in }
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func recordModelUsage() {
|
||||
ModelStorageManager.shared.updateLastUsed(for: modelInfo.modelName)
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: .modelUsageUpdated,
|
||||
object: nil,
|
||||
userInfo: ["modelName": modelInfo.modelName]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
import ExyteChat
|
||||
import ExyteMediaPicker
|
||||
import AVFoundation
|
||||
|
||||
struct LLMChatView: View {
|
||||
|
@ -31,103 +32,128 @@ struct LLMChatView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
ChatView(messages: viewModel.messages, chatType: .conversation) { draft in
|
||||
viewModel.sendToLLM(draft: draft)
|
||||
}
|
||||
.setStreamingMessageProvider {
|
||||
viewModel.currentStreamingMessageId
|
||||
}
|
||||
.setAvailableInput(
|
||||
self.title.lowercased().contains("vl") ? .textAndMedia :
|
||||
self.title.lowercased().contains("audio") ? .textAndAudio :
|
||||
(self.title.isEmpty ? .textOnly : .textOnly)
|
||||
)
|
||||
.messageUseMarkdown(true)
|
||||
.setRecorderSettings(recorderSettings)
|
||||
.setThinkingMode(
|
||||
supportsThinkingMode: viewModel.supportsThinkingMode,
|
||||
isEnabled: viewModel.isThinkingModeEnabled,
|
||||
onToggle: {
|
||||
viewModel.toggleThinkingMode()
|
||||
ZStack {
|
||||
ChatView(messages: viewModel.messages, chatType: .conversation) { draft in
|
||||
viewModel.sendToLLM(draft: draft)
|
||||
}
|
||||
)
|
||||
.chatTheme(
|
||||
ChatTheme(
|
||||
colors: .init(
|
||||
messageMyBG: .customBlue.opacity(0.2),
|
||||
messageFriendBG: .clear
|
||||
),
|
||||
images: .init(
|
||||
attach: Image(systemName: "photo"),
|
||||
attachCamera: Image("attachCamera", bundle: .current)
|
||||
.setStreamingMessageProvider {
|
||||
viewModel.currentStreamingMessageId
|
||||
}
|
||||
.setAvailableInput(
|
||||
self.title.lowercased().contains("omni") ? .full:
|
||||
self.title.lowercased().contains("vl") ? .textAndMedia :
|
||||
self.title.lowercased().contains("audio") ? .textAndAudio :
|
||||
(self.title.isEmpty ? .textOnly : .textOnly)
|
||||
)
|
||||
.messageUseMarkdown(true)
|
||||
.setRecorderSettings(recorderSettings)
|
||||
.setThinkingMode(
|
||||
supportsThinkingMode: viewModel.supportsThinkingMode,
|
||||
isEnabled: viewModel.isThinkingModeEnabled,
|
||||
onToggle: {
|
||||
viewModel.toggleThinkingMode()
|
||||
}
|
||||
)
|
||||
.setMediaPickerSelectionParameters(
|
||||
MediaPickerParameters(mediaType: .photo,
|
||||
selectionLimit: 1,
|
||||
showFullscreenPreview: false)
|
||||
)
|
||||
.chatTheme(
|
||||
ChatTheme(
|
||||
colors: .init(
|
||||
messageMyBG: .customBlue.opacity(0.2),
|
||||
messageFriendBG: .clear
|
||||
),
|
||||
images: .init(
|
||||
attach: Image(systemName: "photo"),
|
||||
attachCamera: Image("attachCamera", bundle: .current)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.mediaPickerTheme(
|
||||
main: .init(
|
||||
text: .white,
|
||||
albumSelectionBackground: .customPickerBg,
|
||||
fullscreenPhotoBackground: .customPickerBg,
|
||||
cameraBackground: .black,
|
||||
cameraSelectionBackground: .black
|
||||
),
|
||||
selection: .init(
|
||||
emptyTint: .white,
|
||||
emptyBackground: .black.opacity(0.25),
|
||||
selectedTint: .customBlue,
|
||||
fullscreenTint: .white
|
||||
.mediaPickerTheme(
|
||||
main: .init(
|
||||
text: .white,
|
||||
albumSelectionBackground: .customPickerBg,
|
||||
fullscreenPhotoBackground: .customPickerBg,
|
||||
cameraBackground: .black,
|
||||
cameraSelectionBackground: .black
|
||||
),
|
||||
selection: .init(
|
||||
emptyTint: .white,
|
||||
emptyBackground: .black.opacity(0.25),
|
||||
selectedTint: .customBlue,
|
||||
fullscreenTint: .white
|
||||
)
|
||||
)
|
||||
)
|
||||
.navigationBarTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden()
|
||||
.disabled(viewModel.chatInputUnavilable)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} label: {
|
||||
Image("backArrow", bundle: .current)
|
||||
.navigationBarTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden()
|
||||
.disabled(viewModel.chatInputUnavilable)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} label: {
|
||||
Image("backArrow", bundle: .current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title)
|
||||
.fontWeight(.semibold)
|
||||
.font(.headline)
|
||||
.foregroundColor(.black)
|
||||
|
||||
Text(viewModel.chatStatus)
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color(hex: "AFB3B8"))
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title)
|
||||
.fontWeight(.semibold)
|
||||
.font(.headline)
|
||||
.foregroundColor(.black)
|
||||
|
||||
Text(viewModel.chatStatus)
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color(hex: "AFB3B8"))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
// Settings Button
|
||||
Button(action: { showSettings.toggle() }) {
|
||||
Image(systemName: "gear")
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
ModelSettingsView(showSettings: $showSettings, viewModel: viewModel)
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
// Settings Button
|
||||
Button(action: { showSettings.toggle() }) {
|
||||
Image(systemName: "gear")
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
ModelSettingsView(showSettings: $showSettings, viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.onAppear {
|
||||
viewModel.onStart()
|
||||
}
|
||||
.onDisappear(perform: viewModel.onStop)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .dismissKeyboard)) { _ in
|
||||
// 隐藏键盘
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
.onAppear {
|
||||
viewModel.onStart()
|
||||
}
|
||||
.onDisappear(perform: viewModel.onStop)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .dismissKeyboard)) { _ in
|
||||
// Hidden keyboard
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
|
||||
// Loading overlay
|
||||
if !viewModel.isModelLoaded {
|
||||
Color.black.opacity(0.4)
|
||||
.ignoresSafeArea()
|
||||
.overlay(
|
||||
VStack(spacing: 20) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(1.5)
|
||||
|
||||
Text(NSLocalizedString("Model is loading...", comment: ""))
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.foregroundColor(.white)
|
||||
.font(.headline)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -32,7 +32,9 @@ class ChatHistoryDatabase {
|
|||
private let updatedAt: Column<Date>
|
||||
|
||||
private init() throws {
|
||||
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
|
||||
guard let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first else {
|
||||
throw NSError(domain: "ChatHistoryDatabase", code: -1, userInfo: [NSLocalizedDescriptionKey: "Documents directory not found"])
|
||||
}
|
||||
db = try Connection("\(path)/chatHistory.sqlite3")
|
||||
|
||||
chatHistories = Table("chatHistories")
|
||||
|
@ -174,19 +176,19 @@ class ChatHistoryDatabase {
|
|||
do {
|
||||
let modelInfoData = modelInfoString.data(using: .utf8)!
|
||||
modelInfoObj = try JSONDecoder().decode(ModelInfo.self, from: modelInfoData)
|
||||
print("Successfully decoded ModelInfo from JSON for history: \(history[id])")
|
||||
// print("Successfully decoded ModelInfo from JSON for history: \(history[id])")
|
||||
} catch {
|
||||
print("Failed to decode ModelInfo from JSON, using fallback: \(error)")
|
||||
// print("Failed to decode ModelInfo from JSON, using fallback: \(error)")
|
||||
modelInfoObj = ModelInfo(modelId: history[modelId], isDownloaded: true)
|
||||
}
|
||||
} else {
|
||||
// For backward compatibility
|
||||
print("No modelInfo data found, using fallback for history: \(history[id])")
|
||||
// print("No modelInfo data found, using fallback for history: \(history[id])")
|
||||
modelInfoObj = ModelInfo(modelId: history[modelId], isDownloaded: true)
|
||||
}
|
||||
} catch {
|
||||
// For backward compatibility
|
||||
print("ModelInfo column not found, using fallback for history: \(history[id])")
|
||||
// print("ModelInfo column not found, using fallback for history: \(history[id])")
|
||||
modelInfoObj = ModelInfo(modelId: history[modelId], isDownloaded: true)
|
||||
}
|
||||
|
||||
|
|
|
@ -698,10 +698,7 @@ bool remove_directory_safely(const std::string& path) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Performance measurement initialization
|
||||
auto inference_start_time = std::chrono::high_resolution_clock::now();
|
||||
|
||||
|
||||
// Get initial context state BEFORE inference starts
|
||||
auto* context = _llm->getContext();
|
||||
int initial_prompt_len = 0;
|
||||
|
@ -764,13 +761,6 @@ bool remove_directory_safely(const std::string& path) {
|
|||
#else
|
||||
{
|
||||
#endif
|
||||
// Get initial context state for performance measurement
|
||||
auto context = blockSelf->_llm->getContext();
|
||||
int initial_prompt_len = context->prompt_len;
|
||||
int initial_decode_len = context->gen_seq_len;
|
||||
int64_t initial_prefill_time = context->prefill_us;
|
||||
int64_t initial_decode_time = context->decode_us;
|
||||
|
||||
// Reset stop flag before starting inference
|
||||
blockSelf->_shouldStopInference = false;
|
||||
|
||||
|
@ -785,6 +775,7 @@ bool remove_directory_safely(const std::string& path) {
|
|||
|
||||
// Start inference with initial response processing
|
||||
blockSelf->_llm->response(blockSelf->_history, &os, "<eop>", 1);
|
||||
|
||||
int current_size = 1;
|
||||
int max_new_tokens = 999999;
|
||||
|
||||
|
@ -798,7 +789,7 @@ bool remove_directory_safely(const std::string& path) {
|
|||
current_size++;
|
||||
|
||||
// Small delay to allow UI updates and stop signal processing
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
// std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
|
||||
// Send appropriate end signal based on stop reason
|
||||
|
@ -835,48 +826,32 @@ bool remove_directory_safely(const std::string& path) {
|
|||
inference_end_time - inference_start_time
|
||||
);
|
||||
|
||||
// Get final context state AFTER inference completes
|
||||
int final_prompt_len = context->prompt_len;
|
||||
int final_decode_len = context->gen_seq_len;
|
||||
int64_t final_prefill_time = context->prefill_us;
|
||||
int64_t final_decode_time = context->decode_us;
|
||||
int prompt_len = 0;
|
||||
int decode_len = 0;
|
||||
int64_t prefill_time = 0;
|
||||
int64_t decode_time = 0;
|
||||
|
||||
// Calculate differences for this inference session
|
||||
int current_prompt_len = final_prompt_len - initial_prompt_len;
|
||||
int current_decode_len = final_decode_len - initial_decode_len;
|
||||
int64_t current_prefill_time = final_prefill_time - initial_prefill_time;
|
||||
int64_t current_decode_time = final_decode_time - initial_decode_time;
|
||||
prompt_len += context->prompt_len;
|
||||
decode_len += context->gen_seq_len;
|
||||
prefill_time += context->prefill_us;
|
||||
decode_time += context->decode_us;
|
||||
|
||||
// Convert microseconds to seconds
|
||||
float prefill_s = static_cast<float>(current_prefill_time) / 1e6f;
|
||||
float decode_s = static_cast<float>(current_decode_time) / 1e6f;
|
||||
float prefill_s = static_cast<float>(prefill_time) / 1e6f;
|
||||
float decode_s = static_cast<float>(decode_time) / 1e6f;
|
||||
|
||||
// Calculate speeds (tokens per second)
|
||||
float prefill_speed = (prefill_s > 0.001f) ?
|
||||
static_cast<float>(current_prompt_len) / prefill_s : 0.0f;
|
||||
static_cast<float>(prompt_len) / prefill_s : 0.0f;
|
||||
float decode_speed = (decode_s > 0.001f) ?
|
||||
static_cast<float>(current_decode_len) / decode_s : 0.0f;
|
||||
static_cast<float>(decode_len) / decode_s : 0.0f;
|
||||
|
||||
// Format performance results with better formatting
|
||||
// Format performance results in 2-line format
|
||||
std::ostringstream performance_output;
|
||||
performance_output << "\n\n > Performance Metrics:\n"
|
||||
<< "Total inference time: " << total_inference_time.count() << " ms\n"
|
||||
<< " Prompt tokens: " << current_prompt_len << "\n"
|
||||
<< "Generated tokens: " << current_decode_len << "\n"
|
||||
<< "Prefill time: " << std::fixed << std::setprecision(3) << prefill_s << " s\n"
|
||||
<< "Decode time: " << std::fixed << std::setprecision(3) << decode_s << " s\n"
|
||||
<< "Prefill speed: " << std::fixed << std::setprecision(1) << prefill_speed << " tok/s\n"
|
||||
<< "Decode speed: " << std::fixed << std::setprecision(1) << decode_speed << " tok/s\n";
|
||||
|
||||
// Add efficiency metrics
|
||||
if (current_prompt_len > 0 && current_decode_len > 0) {
|
||||
float total_tokens = static_cast<float>(current_prompt_len + current_decode_len);
|
||||
float total_time_s = static_cast<float>(total_inference_time.count()) / 1000.0f;
|
||||
float overall_speed = total_time_s > 0.001f ? total_tokens / total_time_s : 0.0f;
|
||||
|
||||
performance_output << "> Overall speed: " << std::fixed << std::setprecision(1)
|
||||
<< overall_speed << " tok/s\n";
|
||||
}
|
||||
performance_output << "\n\nPrefill: " << std::fixed << std::setprecision(2) << prefill_s << "s, "
|
||||
<< prompt_len << " tokens, " << std::setprecision(2) << prefill_speed << " tokens/s\n"
|
||||
<< "Decode: " << std::fixed << std::setprecision(2) << decode_s << "s, "
|
||||
<< decode_len << " tokens, " << std::setprecision(2) << decode_speed << " tokens/s\n";
|
||||
|
||||
// Output performance results on main queue
|
||||
std::string perf_str = performance_output.str();
|
||||
|
|
|
@ -69,7 +69,14 @@
|
|||
}
|
||||
},
|
||||
"Audio Message" : {
|
||||
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "语音信息"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Benchmark" : {
|
||||
"comment" : "基准测试标签",
|
||||
|
@ -112,7 +119,7 @@
|
|||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"state" : "translated",
|
||||
"value" : "清除"
|
||||
}
|
||||
}
|
||||
|
@ -144,7 +151,7 @@
|
|||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"state" : "translated",
|
||||
"value" : "完成"
|
||||
}
|
||||
}
|
||||
|
@ -218,17 +225,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Chat" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "对话"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChatHistroyTitle" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
@ -283,6 +279,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Complete duration" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "完成时长"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Completed" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
|
@ -303,6 +309,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Decode Speed" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "解码速度"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Delete" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
|
@ -380,7 +396,7 @@
|
|||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"state" : "translated",
|
||||
"value" : "按标签筛选"
|
||||
}
|
||||
}
|
||||
|
@ -396,7 +412,7 @@
|
|||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"state" : "translated",
|
||||
"value" : "按厂商筛选"
|
||||
}
|
||||
}
|
||||
|
@ -412,12 +428,22 @@
|
|||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"state" : "translated",
|
||||
"value" : "筛选选项"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Generation rate" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "生成速率"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Help" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
|
@ -490,13 +516,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Local Model" : {
|
||||
"comment" : "本地模型标签",
|
||||
"Memory Usage" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "本地模型"
|
||||
"value" : "内存使用"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -510,6 +535,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Model is loading..." : {
|
||||
|
||||
},
|
||||
"Model Market" : {
|
||||
"comment" : "模型市场标签",
|
||||
|
@ -583,12 +611,33 @@
|
|||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"state" : "translated",
|
||||
"value" : "下载源"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"My Model" : {
|
||||
"comment" : "我的模型标签",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "我的模型"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"N/A" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "不可用"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
|
@ -629,6 +678,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Peak memory" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "峰值内存"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Penalty Sampler" : {
|
||||
|
||||
},
|
||||
|
@ -655,8 +714,25 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Prefill Speed" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "预填充速度"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Progress" : {
|
||||
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "进展"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Random Seed" : {
|
||||
"localizations" : {
|
||||
|
@ -669,7 +745,14 @@
|
|||
}
|
||||
},
|
||||
"Ready" : {
|
||||
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "准备"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Running performance tests" : {
|
||||
"localizations" : {
|
||||
|
@ -1240,6 +1323,38 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Tokens per second" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "每秒令牌数"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Total Time" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "总时间"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Total Tokens" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "总令牌数"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Use HuggingFace to download" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
|
@ -1317,12 +1432,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"搜索本地模型..." : {
|
||||
"搜索模型..." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Search local models …"
|
||||
"value" : "Search Models…"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "搜索模型"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,9 +2,13 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.kernel.increased-memory-limit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -19,9 +19,11 @@ class BenchmarkResultsHelper {
|
|||
// MARK: - Results Processing & Statistics
|
||||
|
||||
/// Processes test results to generate comprehensive benchmark statistics
|
||||
/// - Parameter testResults: Array of completed test instances
|
||||
/// - Parameters:
|
||||
/// - testResults: Array of completed test instances
|
||||
/// - totalTimeSeconds: Total benchmark runtime from start to completion
|
||||
/// - Returns: Processed statistics including speed metrics and configuration details
|
||||
func processTestResults(_ testResults: [TestInstance]) -> BenchmarkStatistics {
|
||||
func processTestResults(_ testResults: [TestInstance], totalTimeSeconds: Float = 0.0) -> BenchmarkStatistics {
|
||||
guard !testResults.isEmpty else {
|
||||
return BenchmarkStatistics.empty
|
||||
}
|
||||
|
@ -65,7 +67,8 @@ class BenchmarkResultsHelper {
|
|||
prefillStats: prefillStats,
|
||||
decodeStats: decodeStats,
|
||||
totalTokensProcessed: totalTokensProcessed,
|
||||
totalTests: testResults.count
|
||||
totalTests: testResults.count,
|
||||
totalTimeSeconds: Double(totalTimeSeconds)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -14,11 +14,13 @@ struct BenchmarkResults {
|
|||
let maxMemoryKb: Int64
|
||||
let testResults: [TestInstance]
|
||||
let timestamp: String
|
||||
let totalTimeSeconds: Float
|
||||
|
||||
init(modelDisplayName: String, maxMemoryKb: Int64, testResults: [TestInstance], timestamp: String) {
|
||||
init(modelDisplayName: String, maxMemoryKb: Int64, testResults: [TestInstance], timestamp: String, totalTimeSeconds: Float = 0.0) {
|
||||
self.modelDisplayName = modelDisplayName
|
||||
self.maxMemoryKb = maxMemoryKb
|
||||
self.testResults = testResults
|
||||
self.timestamp = timestamp
|
||||
self.totalTimeSeconds = totalTimeSeconds
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,12 +15,14 @@ struct BenchmarkStatistics {
|
|||
let decodeStats: SpeedStatistics?
|
||||
let totalTokensProcessed: Int
|
||||
let totalTests: Int
|
||||
let totalTimeSeconds: Double
|
||||
|
||||
static let empty = BenchmarkStatistics(
|
||||
configText: "",
|
||||
prefillStats: nil,
|
||||
decodeStats: nil,
|
||||
totalTokensProcessed: 0,
|
||||
totalTests: 0
|
||||
totalTests: 0,
|
||||
totalTimeSeconds: 0.0
|
||||
)
|
||||
}
|
||||
|
|
|
@ -43,6 +43,10 @@ class BenchmarkViewModel: ObservableObject {
|
|||
// Model list manager for getting local models
|
||||
private let modelListManager = ModelListManager.shared
|
||||
|
||||
// Track total benchmark runtime from start to completion
|
||||
private var totalBenchmarkTimeSeconds: Float = 0.0
|
||||
private var benchmarkStartTime: Date?
|
||||
|
||||
// MARK: - Initialization & Setup
|
||||
|
||||
init() {
|
||||
|
@ -78,7 +82,7 @@ class BenchmarkViewModel: ObservableObject {
|
|||
|
||||
// Filter only downloaded models that are available locally
|
||||
availableModels = allModels.filter { model in
|
||||
model.isDownloaded && model.localPath != ""
|
||||
model.isDownloaded && model.localPath != "" && !model.modelName.lowercased().contains("omni")
|
||||
}
|
||||
|
||||
print("BenchmarkViewModel: Loaded \(availableModels.count) available local models")
|
||||
|
@ -218,6 +222,8 @@ class BenchmarkViewModel: ObservableObject {
|
|||
startButtonText = String(localized: "Stop Test")
|
||||
showProgressBar = true
|
||||
showResults = false
|
||||
totalBenchmarkTimeSeconds = 0.0
|
||||
benchmarkStartTime = Date()
|
||||
updateStatus("Initializing benchmark...")
|
||||
}
|
||||
|
||||
|
@ -229,6 +235,7 @@ class BenchmarkViewModel: ObservableObject {
|
|||
showProgressBar = false
|
||||
hideStatus()
|
||||
showResults = false
|
||||
benchmarkStartTime = nil
|
||||
cleanupBenchmarkResources()
|
||||
}
|
||||
|
||||
|
@ -309,6 +316,11 @@ extension BenchmarkViewModel: BenchmarkCallback {
|
|||
let formattedProgress = formatProgressMessage(progress)
|
||||
currentProgress = formattedProgress
|
||||
updateStatus(formattedProgress.statusMessage)
|
||||
|
||||
// Calculate the total runtime from benchmark start to current point
|
||||
if let startTime = benchmarkStartTime {
|
||||
totalBenchmarkTimeSeconds = Float(Date().timeIntervalSince(startTime))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles benchmark completion with results processing
|
||||
|
@ -322,7 +334,8 @@ extension BenchmarkViewModel: BenchmarkCallback {
|
|||
modelDisplayName: model.modelName,
|
||||
maxMemoryKb: MemoryMonitor.shared.getMaxMemoryKb(),
|
||||
testResults: [result.testInstance],
|
||||
timestamp: DateFormatter.benchmarkTimestamp.string(from: Date())
|
||||
timestamp: DateFormatter.benchmarkTimestamp.string(from: Date()),
|
||||
totalTimeSeconds: totalBenchmarkTimeSeconds
|
||||
)
|
||||
|
||||
benchmarkResults = results
|
||||
|
@ -417,22 +430,22 @@ class MemoryMonitor: ObservableObject {
|
|||
maxMemoryKb = max(maxMemoryKb, memoryUsage)
|
||||
}
|
||||
|
||||
/// Gets current memory usage from system using mach task info
|
||||
/// Gets current memory usage from system using task_vm_info for physical footprint
|
||||
private func getCurrentMemoryUsage() -> Int64 {
|
||||
var info = mach_task_basic_info()
|
||||
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
|
||||
var info = task_vm_info_data_t()
|
||||
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size) / UInt32(MemoryLayout<integer_t>.size)
|
||||
|
||||
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
|
||||
$0.withMemoryRebound(to: integer_t.self, capacity: 1) {
|
||||
task_info(mach_task_self_,
|
||||
task_flavor_t(MACH_TASK_BASIC_INFO),
|
||||
task_flavor_t(TASK_VM_INFO),
|
||||
$0,
|
||||
&count)
|
||||
}
|
||||
}
|
||||
|
||||
if kerr == KERN_SUCCESS {
|
||||
return Int64(info.resident_size) / 1024 // Convert to KB
|
||||
return Int64(info.phys_footprint) / 1024 // Convert to KB
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ struct ModelSelectionCard: View {
|
|||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Select Model")
|
||||
Text(String(localized: "Select Model"))
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
@ -27,7 +27,7 @@ struct ModelSelectionCard: View {
|
|||
HStack {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Loading models...")
|
||||
Text(String(localized: "Loading models..."))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
@ -56,7 +56,7 @@ struct ModelSelectionCard: View {
|
|||
private var modelDropdownMenu: some View {
|
||||
Menu {
|
||||
if viewModel.availableModels.isEmpty {
|
||||
Button("No models available") {
|
||||
Button(String(localized: "No models available")) {
|
||||
// Placeholder - no action
|
||||
}
|
||||
.disabled(true)
|
||||
|
@ -69,7 +69,7 @@ struct ModelSelectionCard: View {
|
|||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.modelName)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text("Local")
|
||||
Text(String(localized: "Local"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
@ -91,7 +91,7 @@ struct ModelSelectionCard: View {
|
|||
Circle()
|
||||
.fill(Color.benchmarkSuccess)
|
||||
.frame(width: 6, height: 6)
|
||||
Text("Ready")
|
||||
Text(String(localized: "Ready"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSuccess)
|
||||
}
|
||||
|
@ -103,7 +103,7 @@ struct ModelSelectionCard: View {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
Text("Tap to select a model for testing")
|
||||
Text(String(localized: "Tap to select a model for testing"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSecondary)
|
||||
}
|
||||
|
@ -202,12 +202,12 @@ struct ModelSelectionCard: View {
|
|||
private var statusMessages: some View {
|
||||
Group {
|
||||
if viewModel.selectedModel == nil {
|
||||
Text("Start benchmark after selecting your model")
|
||||
Text(String(localized: "Start benchmark after selecting your model"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 16)
|
||||
} else if viewModel.availableModels.isEmpty {
|
||||
Text("No local models found. Please download a model first.")
|
||||
Text(String(localized: "No local models found. Please download a model first."))
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 16)
|
||||
|
|
|
@ -78,17 +78,17 @@ struct PerformanceMetricView: View {
|
|||
HStack(spacing: 12) {
|
||||
PerformanceMetricView(
|
||||
icon: "speedometer",
|
||||
title: "Prefill Speed",
|
||||
title: String(localized: "Prefill Speed"),
|
||||
value: "1024.5 t/s",
|
||||
subtitle: "Tokens per second",
|
||||
subtitle: String(localized: "Tokens per second"),
|
||||
color: .benchmarkGradientStart
|
||||
)
|
||||
|
||||
PerformanceMetricView(
|
||||
icon: "gauge",
|
||||
title: "Decode Speed",
|
||||
title: String(localized: "Decode Speed"),
|
||||
value: "109.8 t/s",
|
||||
subtitle: "Generation rate",
|
||||
subtitle: String(localized: "Generation rate"),
|
||||
color: .benchmarkGradientEnd
|
||||
)
|
||||
}
|
||||
|
@ -96,17 +96,17 @@ struct PerformanceMetricView: View {
|
|||
HStack(spacing: 12) {
|
||||
PerformanceMetricView(
|
||||
icon: "memorychip",
|
||||
title: "Memory Usage",
|
||||
title: String(localized: "Memory Usage"),
|
||||
value: "1.2 GB",
|
||||
subtitle: "Peak memory",
|
||||
subtitle: String(localized: "Peak memory"),
|
||||
color: .benchmarkWarning
|
||||
)
|
||||
|
||||
PerformanceMetricView(
|
||||
icon: "clock",
|
||||
title: "Total Time",
|
||||
title: String(localized: "Total Time"),
|
||||
value: "2.456s",
|
||||
subtitle: "Complete duration",
|
||||
subtitle: String(localized: "Complete duration"),
|
||||
color: .benchmarkSuccess
|
||||
)
|
||||
}
|
||||
|
|
|
@ -60,12 +60,12 @@ struct ProgressCard: View {
|
|||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Test Progress")
|
||||
Text(String(localized: "Test Progress"))
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Running performance tests")
|
||||
Text(String(localized: "Running performance tests"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSecondary)
|
||||
}
|
||||
|
@ -79,7 +79,7 @@ struct ProgressCard: View {
|
|||
.fontWeight(.bold)
|
||||
.foregroundColor(.benchmarkAccent)
|
||||
|
||||
Text("Complete")
|
||||
Text(String(localized: "Complete"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSecondary)
|
||||
}
|
||||
|
@ -156,7 +156,7 @@ struct ProgressCard: View {
|
|||
|
||||
private var fallbackProgress: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Progress")
|
||||
Text(String(localized: "Progress"))
|
||||
.font(.headline)
|
||||
ProgressView()
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
|
|
|
@ -34,7 +34,7 @@ struct ResultsCard: View {
|
|||
|
||||
private var infoHeader: some View {
|
||||
|
||||
let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults)
|
||||
let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults, totalTimeSeconds: results.totalTimeSeconds)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
Text(results.modelDisplayName)
|
||||
|
@ -43,7 +43,7 @@ struct ResultsCard: View {
|
|||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Benchmark Config")
|
||||
Text(String(localized: "Benchmark Config"))
|
||||
.font(.headline)
|
||||
Text(statistics.configText)
|
||||
.font(.subheadline)
|
||||
|
@ -73,12 +73,12 @@ struct ResultsCard: View {
|
|||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Benchmark Results")
|
||||
Text(String(localized: "Benchmark Results"))
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Performance analysis complete")
|
||||
Text(String(localized: "Performance analysis complete"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSecondary)
|
||||
}
|
||||
|
@ -94,7 +94,7 @@ struct ResultsCard: View {
|
|||
.font(.title2)
|
||||
.foregroundColor(.benchmarkSuccess)
|
||||
|
||||
Text("Share")
|
||||
Text(String(localized: "Share"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSecondary)
|
||||
}
|
||||
|
@ -105,24 +105,24 @@ struct ResultsCard: View {
|
|||
|
||||
|
||||
private var performanceMetrics: some View {
|
||||
let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults)
|
||||
let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults, totalTimeSeconds: results.totalTimeSeconds)
|
||||
|
||||
return VStack(spacing: 16) {
|
||||
HStack(spacing: 12) {
|
||||
if let prefillStats = statistics.prefillStats {
|
||||
PerformanceMetricView(
|
||||
icon: "speedometer",
|
||||
title: "Prefill Speed",
|
||||
title: String(localized: "Prefill Speed"),
|
||||
value: BenchmarkResultsHelper.shared.formatSpeedStatisticsLine(prefillStats),
|
||||
subtitle: "Tokens per second",
|
||||
subtitle: String(localized: "Tokens per second"),
|
||||
color: .benchmarkGradientStart
|
||||
)
|
||||
} else {
|
||||
PerformanceMetricView(
|
||||
icon: "speedometer",
|
||||
title: "Prefill Speed",
|
||||
value: "N/A",
|
||||
subtitle: "Tokens per second",
|
||||
title: String(localized: "Prefill Speed"),
|
||||
value: String(localized: "N/A"),
|
||||
subtitle: String(localized: "Tokens per second"),
|
||||
color: .benchmarkGradientStart
|
||||
)
|
||||
}
|
||||
|
@ -130,17 +130,17 @@ struct ResultsCard: View {
|
|||
if let decodeStats = statistics.decodeStats {
|
||||
PerformanceMetricView(
|
||||
icon: "gauge",
|
||||
title: "Decode Speed",
|
||||
title: String(localized: "Decode Speed"),
|
||||
value: BenchmarkResultsHelper.shared.formatSpeedStatisticsLine(decodeStats),
|
||||
subtitle: "Generation rate",
|
||||
subtitle: String(localized: "Generation rate"),
|
||||
color: .benchmarkGradientEnd
|
||||
)
|
||||
} else {
|
||||
PerformanceMetricView(
|
||||
icon: "gauge",
|
||||
title: "Decode Speed",
|
||||
value: "N/A",
|
||||
subtitle: "Generation rate",
|
||||
title: String(localized: "Decode Speed"),
|
||||
value: String(localized: "N/A"),
|
||||
subtitle: String(localized: "Generation rate"),
|
||||
color: .benchmarkGradientEnd
|
||||
)
|
||||
}
|
||||
|
@ -155,17 +155,17 @@ struct ResultsCard: View {
|
|||
|
||||
PerformanceMetricView(
|
||||
icon: "memorychip",
|
||||
title: "Memory Usage",
|
||||
title: String(localized: "Memory Usage"),
|
||||
value: memoryInfo.valueText,
|
||||
subtitle: "Peak memory",
|
||||
subtitle: String(localized: "Peak memory"),
|
||||
color: .benchmarkWarning
|
||||
)
|
||||
|
||||
PerformanceMetricView(
|
||||
icon: "clock",
|
||||
title: "Total Tokens",
|
||||
value: "\(statistics.totalTokensProcessed)",
|
||||
subtitle: "Complete duration",
|
||||
title: String(localized: "Total Time"),
|
||||
value: String(format: "%.2f s", statistics.totalTimeSeconds),
|
||||
subtitle: String(localized: "Complete duration"),
|
||||
color: .benchmarkSuccess
|
||||
)
|
||||
}
|
||||
|
@ -176,9 +176,9 @@ struct ResultsCard: View {
|
|||
return VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text("Completed")
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSecondary)
|
||||
Text(String(localized: "Completed"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSecondary)
|
||||
Spacer()
|
||||
Text(results.timestamp)
|
||||
.font(.caption)
|
||||
|
@ -186,9 +186,9 @@ struct ResultsCard: View {
|
|||
}
|
||||
|
||||
HStack {
|
||||
Text("Powered By MNN")
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSecondary)
|
||||
Text(String(localized: "Powered By MNN"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.benchmarkSecondary)
|
||||
Spacer()
|
||||
Text(verbatim: "https://github.com/alibaba/MNN")
|
||||
.font(.caption)
|
||||
|
@ -238,7 +238,7 @@ struct ResultsCard: View {
|
|||
|
||||
/// Formats benchmark results into shareable text format with performance metrics and hashtags
|
||||
private func formatResultsForSharing() -> String {
|
||||
let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults)
|
||||
let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults, totalTimeSeconds: results.totalTimeSeconds)
|
||||
let deviceInfo = BenchmarkResultsHelper.shared.getDeviceInfo()
|
||||
|
||||
var shareText = """
|
||||
|
|
|
@ -31,7 +31,7 @@ struct StatusCard: View {
|
|||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Status Update")
|
||||
Text(String(localized: "Status Update"))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
|
|
@ -51,16 +51,16 @@ struct BenchmarkView: View {
|
|||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
.alert("Stop Benchmark", isPresented: $viewModel.showStopConfirmation) {
|
||||
Button("Yes", role: .destructive) {
|
||||
.alert(String(localized: "Stop Benchmark"), isPresented: $viewModel.showStopConfirmation) {
|
||||
Button(String(localized: "Yes"), role: .destructive) {
|
||||
viewModel.onStopBenchmarkTapped()
|
||||
}
|
||||
Button("No", role: .cancel) { }
|
||||
Button(String(localized: "No"), role: .cancel) { }
|
||||
} message: {
|
||||
Text("Are you sure you want to stop the benchmark test?")
|
||||
Text(String(localized: "Are you sure you want to stop the benchmark test?"))
|
||||
}
|
||||
.alert("Error", isPresented: $viewModel.showError) {
|
||||
Button("OK") {
|
||||
.alert(String(localized: "Error"), isPresented: $viewModel.showError) {
|
||||
Button(String(localized: "OK")) {
|
||||
viewModel.dismissError()
|
||||
}
|
||||
} message: {
|
||||
|
|
|
@ -42,7 +42,7 @@ struct LocalModelListView: View {
|
|||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.searchable(text: $localSearchText, prompt: "搜索本地模型...")
|
||||
.searchable(text: $localSearchText, prompt: "搜索模型...")
|
||||
.refreshable {
|
||||
await viewModel.fetchModels()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// MainTabItem.swift
|
||||
// MNNLLMiOS
|
||||
//
|
||||
// Created by 游薪渝(揽清) on 2025/9/3.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabItem: View {
|
||||
let imageName: String
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image(imageName)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(title)
|
||||
}
|
||||
.foregroundColor(isSelected ? .primaryPurple : .gray)
|
||||
}
|
||||
}
|
|
@ -27,7 +27,7 @@ struct MainTabView: View {
|
|||
|
||||
private var titles: [String] {
|
||||
[
|
||||
NSLocalizedString("Local Model", comment: "本地模型标签"),
|
||||
NSLocalizedString("My Model", comment: "我的模型标签"),
|
||||
NSLocalizedString("Model Market", comment: "模型市场标签"),
|
||||
NSLocalizedString("Benchmark", comment: "基准测试标签")
|
||||
]
|
||||
|
@ -40,21 +40,21 @@ struct MainTabView: View {
|
|||
createTabContent(
|
||||
content: LocalModelListView(viewModel: modelListViewModel),
|
||||
title: titles[0],
|
||||
icon: "house.fill",
|
||||
icon: "home",
|
||||
tag: 0
|
||||
)
|
||||
|
||||
createTabContent(
|
||||
content: ModelListView(viewModel: modelListViewModel),
|
||||
title: titles[1],
|
||||
icon: "doc.text.fill",
|
||||
icon: "market",
|
||||
tag: 1
|
||||
)
|
||||
|
||||
createTabContent(
|
||||
content: BenchmarkView(),
|
||||
title: titles[2],
|
||||
icon: "clock.fill",
|
||||
icon: "benchmark",
|
||||
tag: 2
|
||||
)
|
||||
}
|
||||
|
@ -143,17 +143,26 @@ struct MainTabView: View {
|
|||
showHistoryButton: $showHistoryButton
|
||||
)
|
||||
}
|
||||
.navigationDestination(isPresented: $navigateToChat) {
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { navigateToChat && selectedTab == tag },
|
||||
set: { _ in navigateToChat = false }
|
||||
)) {
|
||||
chatDestination
|
||||
}
|
||||
.navigationDestination(isPresented: $navigateToSettings) {
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { navigateToSettings && selectedTab == tag },
|
||||
set: { _ in navigateToSettings = false }
|
||||
)) {
|
||||
SettingsView()
|
||||
}
|
||||
.toolbar((navigateToChat || navigateToSettings) ? .hidden : .visible, for: .tabBar)
|
||||
}
|
||||
.tabItem {
|
||||
Image(systemName: icon)
|
||||
Text(title)
|
||||
MainTabItem(
|
||||
imageName: selectedTab == tag ? "\(icon)Fill" : icon,
|
||||
title: title,
|
||||
isSelected: selectedTab == tag
|
||||
)
|
||||
}
|
||||
.tag(tag)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
class ModelListViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
|
@ -21,8 +22,9 @@ class ModelListViewModel: ObservableObject {
|
|||
@Published private(set) var currentlyDownloading: String?
|
||||
|
||||
// MARK: - Private Properties
|
||||
private let modelClient = ModelClient()
|
||||
private let modelClient = ModelClient.shared
|
||||
private let pinnedModelKey = "com.mnnllm.pinnedModelIds"
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Model Data Access
|
||||
|
||||
|
@ -49,6 +51,17 @@ class ModelListViewModel: ObservableObject {
|
|||
Task { @MainActor in
|
||||
await fetchModels()
|
||||
}
|
||||
|
||||
NotificationCenter.default
|
||||
.publisher(for: .modelUsageUpdated)
|
||||
.sink { [weak self] notification in
|
||||
if let modelName = notification.userInfo?["modelName"] as? String {
|
||||
Task { @MainActor in
|
||||
self?.updateModelLastUsed(modelName: modelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Model Data Management
|
||||
|
@ -357,7 +370,10 @@ class ModelListViewModel: ObservableObject {
|
|||
await MainActor.run {
|
||||
guard currentlyDownloading == nil else { return }
|
||||
currentlyDownloading = model.id
|
||||
downloadProgress[model.id] = 0
|
||||
|
||||
if downloadProgress[model.id] == nil {
|
||||
downloadProgress[model.id] = 0
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
|
@ -372,6 +388,9 @@ class ModelListViewModel: ObservableObject {
|
|||
self.models[index].isDownloaded = true
|
||||
ModelStorageManager.shared.markModelAsDownloaded(model.modelName)
|
||||
}
|
||||
|
||||
self.downloadProgress.removeValue(forKey: model.id)
|
||||
self.currentlyDownloading = nil
|
||||
}
|
||||
|
||||
// Calculate and cache size for newly downloaded model
|
||||
|
@ -392,26 +411,22 @@ class ModelListViewModel: ObservableObject {
|
|||
if case ModelScopeError.downloadCancelled = error {
|
||||
print("Download was cancelled")
|
||||
} else {
|
||||
self.downloadProgress.removeValue(forKey: model.id)
|
||||
self.showError = true
|
||||
self.errorMessage = "Failed to download model: \(error.localizedDescription)"
|
||||
}
|
||||
self.currentlyDownloading = nil
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.currentlyDownloading = nil
|
||||
self.downloadProgress.removeValue(forKey: model.id)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelDownload() async {
|
||||
let modelId = await MainActor.run { currentlyDownloading }
|
||||
|
||||
if let modelId = modelId {
|
||||
await modelClient.cancelDownload()
|
||||
await modelClient.cancelDownload(for: modelId)
|
||||
|
||||
await MainActor.run {
|
||||
self.downloadProgress.removeValue(forKey: modelId)
|
||||
self.currentlyDownloading = nil
|
||||
}
|
||||
|
||||
|
@ -488,4 +503,20 @@ class ModelListViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateModelLastUsed(modelName: String) {
|
||||
if let index = models.firstIndex(where: { $0.modelName == modelName }) {
|
||||
if let lastUsed = ModelStorageManager.shared.getLastUsed(for: modelName) {
|
||||
models[index].lastUsedAt = lastUsed
|
||||
sortModels(fetchedModels: &models)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Names
|
||||
|
||||
extension Notification.Name {
|
||||
static let modelUsageUpdated = Notification.Name("modelUsageUpdated")
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import SwiftUI
|
|||
|
||||
struct HelpView: View {
|
||||
var body: some View {
|
||||
WebView(url: URL(string: "https://github.com/alibaba/MNN")!) // ?tab=readme-ov-file#intro
|
||||
WebView(url: URL(string: "https://github.com/alibaba/MNN") ?? URL(fileURLWithPath: "/")) // ?tab=readme-ov-file#intro
|
||||
.navigationTitle("Help")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
|
|
@ -16,27 +16,48 @@ struct ModelListView: View {
|
|||
@State private var selectedCategories: Set<String> = []
|
||||
@State private var selectedVendors: Set<String> = []
|
||||
@State private var showFilterMenu = false
|
||||
private let topID = "topID"
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
Section {
|
||||
modelListSection
|
||||
} header: {
|
||||
toolbarSection
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
|
||||
Color.clear.frame(height: 0).id(topID)
|
||||
|
||||
Section {
|
||||
modelListSection
|
||||
} header: {
|
||||
toolbarSection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search models...")
|
||||
.refreshable {
|
||||
await viewModel.fetchModels()
|
||||
}
|
||||
.alert("Error", isPresented: $viewModel.showError) {
|
||||
Button("OK") {
|
||||
viewModel.dismissError()
|
||||
.searchable(text: $searchText, prompt: "Search models...")
|
||||
.refreshable {
|
||||
await viewModel.fetchModels()
|
||||
}
|
||||
.alert("Error", isPresented: $viewModel.showError) {
|
||||
Button("OK") {
|
||||
viewModel.dismissError()
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage)
|
||||
}
|
||||
// Auto-scroll to top when filters change to avoid blank screen when data shrinks
|
||||
.onChange(of: selectedTags) { old, new in
|
||||
withAnimation { proxy.scrollTo(topID, anchor: .top) }
|
||||
}
|
||||
.onChange(of: selectedCategories) { old, new in
|
||||
withAnimation { proxy.scrollTo(topID, anchor: .top) }
|
||||
}
|
||||
.onChange(of: selectedVendors) { old, new in
|
||||
withAnimation { proxy.scrollTo(topID, anchor: .top) }
|
||||
}
|
||||
.onChange(of: showFilterMenu) { old, new in
|
||||
if old != new {
|
||||
withAnimation { proxy.scrollTo(topID, anchor: .top) }
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -28,13 +28,16 @@ import Foundation
|
|||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// let client = ModelClient()
|
||||
/// let client = ModelClient.shared
|
||||
/// let models = try await client.getModelInfo()
|
||||
/// try await client.downloadModel(model: selectedModel) { progress in
|
||||
/// print("Download progress: \(progress * 100)%")
|
||||
/// }
|
||||
/// ```
|
||||
class ModelClient {
|
||||
// MARK: - Singleton
|
||||
static let shared = ModelClient()
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let maxRetries = 5
|
||||
|
@ -46,8 +49,9 @@ class ModelClient {
|
|||
// Debug flag to use local mock data instead of network API
|
||||
private let useLocalMockData = false
|
||||
|
||||
private var currentDownloadManager: ModelDownloadManagerProtocol?
|
||||
private var downloadManagers: [String: ModelDownloadManagerProtocol] = [:]
|
||||
private let downloadManagerFactory: ModelDownloadManagerFactory
|
||||
private let downloadManagerQueue = DispatchQueue(label: "com.mnn.downloadManager", attributes: .concurrent)
|
||||
|
||||
private lazy var baseURLString: String = {
|
||||
switch ModelSourceManager.shared.selectedSource {
|
||||
|
@ -58,14 +62,20 @@ class ModelClient {
|
|||
}
|
||||
}()
|
||||
|
||||
/// Creates a ModelClient with dependency injection for download manager
|
||||
/// Private initializer for singleton pattern
|
||||
///
|
||||
/// - Parameter downloadManagerFactory: Factory for creating download managers.
|
||||
/// Defaults to DefaultModelDownloadManagerFactory
|
||||
init(downloadManagerFactory: ModelDownloadManagerFactory = DefaultModelDownloadManagerFactory()) {
|
||||
/// Defaults to LegacyModelDownloadManagerFactory
|
||||
private init(downloadManagerFactory: ModelDownloadManagerFactory = LegacyModelDownloadManagerFactory()) {
|
||||
print("ModelClient singleton initialized")
|
||||
self.downloadManagerFactory = downloadManagerFactory
|
||||
}
|
||||
|
||||
deinit {
|
||||
print("ModelClient deinit")
|
||||
downloadManagers.removeAll()
|
||||
}
|
||||
|
||||
/// Retrieves model information from the configured API endpoint
|
||||
///
|
||||
/// This method fetches the latest model catalog from the network API.
|
||||
|
@ -140,15 +150,30 @@ class ModelClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Cancels the current download operation
|
||||
func cancelDownload() async {
|
||||
switch ModelSourceManager.shared.selectedSource {
|
||||
case .modelScope, .modeler:
|
||||
await currentDownloadManager?.cancelDownload()
|
||||
case .huggingFace:
|
||||
// TODO: await currentDownloadManager?.cancelDownload()
|
||||
// try await mirrorHubApi.
|
||||
print("cant stop")
|
||||
/// Cancels download for a specific model
|
||||
/// - Parameter modelId: The ID of the model to cancel download for
|
||||
func cancelDownload(for modelId: String) async {
|
||||
downloadManagerQueue.sync {
|
||||
if let downloadManager = downloadManagers[modelId] {
|
||||
Task {
|
||||
await downloadManager.cancelDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels all active downloads
|
||||
func cancelAllDownloads() async {
|
||||
let managers = downloadManagerQueue.sync {
|
||||
return Array(downloadManagers.values)
|
||||
}
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for manager in managers {
|
||||
group.addTask {
|
||||
await manager.cancelDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -161,14 +186,21 @@ class ModelClient {
|
|||
private func downloadFromModelScope(_ model: ModelInfo,
|
||||
source: ModelSource,
|
||||
progress: @escaping (Double) -> Void) async throws {
|
||||
|
||||
currentDownloadManager = downloadManagerFactory.createDownloadManager(
|
||||
repoPath: model.id,
|
||||
source: .modelScope
|
||||
)
|
||||
let downloadManager = downloadManagerQueue.sync {
|
||||
if let existingManager = downloadManagers[model.id] {
|
||||
return existingManager
|
||||
} else {
|
||||
let newManager = downloadManagerFactory.createDownloadManager(
|
||||
repoPath: model.id,
|
||||
source: .modelScope
|
||||
)
|
||||
downloadManagers[model.id] = newManager
|
||||
return newManager
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await currentDownloadManager?.downloadModel(
|
||||
try await downloadManager.downloadModel(
|
||||
to: "huggingface/models/taobao-mnn",
|
||||
modelId: model.id,
|
||||
modelName: model.modelName
|
||||
|
@ -177,14 +209,30 @@ class ModelClient {
|
|||
progress(fileProgress)
|
||||
}
|
||||
}
|
||||
|
||||
await cleanupDownloadManager(for: model.id)
|
||||
|
||||
} catch {
|
||||
if case ModelScopeError.downloadCancelled = error {
|
||||
throw ModelScopeError.downloadCancelled
|
||||
} else {
|
||||
await cleanupDownloadManager(for: model.id)
|
||||
throw NetworkError.downloadFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupDownloadManager(for modelId: String) async {
|
||||
_ = downloadManagerQueue.sync {
|
||||
downloadManagers.removeValue(forKey: modelId)
|
||||
}
|
||||
}
|
||||
|
||||
func getActiveDownloadersCount() -> Int {
|
||||
return downloadManagerQueue.sync {
|
||||
return downloadManagers.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads model from HuggingFace platform with optimized progress updates
|
||||
///
|
||||
|
@ -205,7 +253,7 @@ class ModelClient {
|
|||
var lastUpdateTime = Date()
|
||||
var lastProgress: Double = 0.0
|
||||
let progressUpdateInterval: TimeInterval = 0.1 // Limit update frequency to every 100ms
|
||||
let progressThreshold: Double = 0.01 // Progress change threshold of 1%
|
||||
let progressThreshold: Double = 0.001 // Progress change threshold of 0.1%
|
||||
|
||||
try await mirrorHubApi.snapshot(from: repo, matching: modelFiles) { fileProgress in
|
||||
let currentProgress = fileProgress.fractionCompleted
|
||||
|
|
|
@ -46,11 +46,32 @@ struct ChunkInfo {
|
|||
|
||||
struct DownloadProgress {
|
||||
var totalBytes: Int64 = 0
|
||||
var downloadedBytes: Int64 = 0
|
||||
var activeDownloads: Int = 0
|
||||
var completedFiles: Int = 0
|
||||
var totalFiles: Int = 0
|
||||
|
||||
// Track individual file progress
|
||||
var fileProgress: [String: FileDownloadProgress] = [:]
|
||||
var lastReportedProgress: Double = 0.0
|
||||
|
||||
var progress: Double {
|
||||
guard totalBytes > 0 else { return 0.0 }
|
||||
|
||||
let totalDownloadedBytes = fileProgress.values.reduce(0) { sum, fileProgress in
|
||||
return sum + fileProgress.downloadedBytes
|
||||
}
|
||||
|
||||
let calculatedProgress = Double(totalDownloadedBytes) / Double(totalBytes)
|
||||
return min(calculatedProgress, 1.0) // Ensure progress never exceeds 100%
|
||||
}
|
||||
}
|
||||
|
||||
struct FileDownloadProgress {
|
||||
let fileName: String
|
||||
let totalBytes: Int64
|
||||
var downloadedBytes: Int64 = 0
|
||||
var isCompleted: Bool = false
|
||||
|
||||
var progress: Double {
|
||||
guard totalBytes > 0 else { return 0.0 }
|
||||
return Double(downloadedBytes) / Double(totalBytes)
|
||||
|
|
|
@ -100,7 +100,8 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
self.session = URLSession(configuration: sessionConfig)
|
||||
self.concurrencyManager = DynamicConcurrencyManager(config: concurrencyConfig)
|
||||
self.downloadSemaphore = AsyncSemaphore(value: config.maxConcurrentDownloads)
|
||||
|
||||
print("ModelClient init")
|
||||
|
||||
ModelDownloadLogger.isEnabled = enableLogging
|
||||
}
|
||||
|
||||
|
@ -216,6 +217,10 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
let subFiles = try await fetchFileList(root: file.path, revision: "")
|
||||
try await processFiles(subFiles, destinationPath: newPath)
|
||||
} else if file.type == "blob" {
|
||||
// Initialize progress tracking for all files
|
||||
await initializeFileProgress(fileName: file.name, totalBytes: Int64(file.size))
|
||||
progress.totalBytes += Int64(file.size)
|
||||
|
||||
if !storage.isFileDownloaded(file, at: destinationPath) {
|
||||
var task = DownloadTask(
|
||||
file: file,
|
||||
|
@ -230,9 +235,14 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
}
|
||||
|
||||
downloadQueue.append(task)
|
||||
progress.totalBytes += Int64(file.size)
|
||||
} else {
|
||||
progress.downloadedBytes += Int64(file.size)
|
||||
// File already downloaded, mark as completed in progress tracking
|
||||
if var fileProgress = progress.fileProgress[file.name] {
|
||||
fileProgress.downloadedBytes = fileProgress.totalBytes
|
||||
fileProgress.isCompleted = true
|
||||
progress.fileProgress[file.name] = fileProgress
|
||||
}
|
||||
ModelDownloadLogger.info("File \(file.name) already exists, skipping download")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -261,9 +271,12 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
let startOffset = Int64(i) * chunkSize
|
||||
let endOffset = min(startOffset + chunkSize - 1, fileSize - 1)
|
||||
|
||||
let modelHash = repoPath.hash
|
||||
let fileHash = file.path.hash
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
let modelHash = repoPath.stableHash
|
||||
let fileHash = file.path.stableHash
|
||||
|
||||
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true)
|
||||
let tempURL = downloadsURL
|
||||
.appendingPathComponent("model_\(modelHash)_file_\(fileHash)_chunk_\(i)_\(file.name.sanitizedPath).tmp")
|
||||
|
||||
// Check if chunk already exists and calculate downloaded bytes
|
||||
|
@ -294,6 +307,31 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
return chunks
|
||||
}
|
||||
|
||||
/// Calculate initial progress from existing downloaded files and chunks
|
||||
private func calculateInitialProgress() async {
|
||||
for task in downloadQueue {
|
||||
if task.isChunked {
|
||||
// For chunked files, sum up downloaded bytes from all chunks
|
||||
let chunkBytes = task.chunks.reduce(0) { total, chunk in
|
||||
return total + (chunk.isCompleted ? (chunk.endOffset - chunk.startOffset + 1) : chunk.downloadedBytes)
|
||||
}
|
||||
|
||||
// Update file progress with chunk data
|
||||
if var fileProgress = progress.fileProgress[task.file.name] {
|
||||
fileProgress.downloadedBytes = chunkBytes
|
||||
progress.fileProgress[task.file.name] = fileProgress
|
||||
}
|
||||
}
|
||||
// For non-chunked files, if they exist, they would not be in downloadQueue
|
||||
}
|
||||
|
||||
let totalDownloadedBytes = progress.fileProgress.values.reduce(0) { sum, fileProgress in
|
||||
return sum + fileProgress.downloadedBytes
|
||||
}
|
||||
|
||||
ModelDownloadLogger.info("Initial downloaded bytes: \(totalDownloadedBytes)")
|
||||
}
|
||||
|
||||
// MARK: - Download Execution
|
||||
|
||||
/// Executes download tasks with dynamic concurrency management
|
||||
|
@ -304,6 +342,9 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
///
|
||||
/// - Throws: ModelScopeError if downloads fail or are cancelled
|
||||
private func executeDownloads() async throws {
|
||||
// Calculate initial downloaded bytes from existing files and chunks
|
||||
await calculateInitialProgress()
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for task in downloadQueue {
|
||||
if isCancelled { break }
|
||||
|
@ -350,12 +391,11 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
|
||||
ModelDownloadLogger.info("Using optimal concurrency: \(concurrencyCount) for \(task.chunks.count) chunks")
|
||||
|
||||
// Check if any chunks are already completed and update progress
|
||||
// Check if any chunks are already completed and log progress (but don't update global progress yet)
|
||||
let completedBytes = task.chunks.reduce(0) { total, chunk in
|
||||
return total + (chunk.isCompleted ? (chunk.endOffset - chunk.startOffset + 1) : chunk.downloadedBytes)
|
||||
}
|
||||
if completedBytes > 0 {
|
||||
await updateDownloadProgress(bytes: completedBytes)
|
||||
ModelDownloadLogger.info("Found \(completedBytes) bytes already downloaded for \(task.file.name)")
|
||||
}
|
||||
|
||||
|
@ -435,22 +475,26 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
try fileHandle.seekToEnd()
|
||||
}
|
||||
|
||||
var bytesCount = 0
|
||||
var buffer = Data()
|
||||
buffer.reserveCapacity(512 * 1024) // Reserve 512KB buffer for chunks
|
||||
|
||||
for try await byte in asyncBytes {
|
||||
if isCancelled { throw ModelScopeError.downloadCancelled }
|
||||
|
||||
try fileHandle.write(contentsOf: [byte])
|
||||
bytesCount += 1
|
||||
buffer.append(byte)
|
||||
|
||||
if bytesCount >= 64 * 1024 {
|
||||
await updateDownloadProgress(bytes: Int64(bytesCount))
|
||||
bytesCount = 0
|
||||
// Write in larger chunks to reduce I/O operations
|
||||
if buffer.count >= 128 * 1024 { // 128KB chunks for chunk downloads
|
||||
try fileHandle.write(contentsOf: buffer)
|
||||
await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count))
|
||||
buffer.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
if bytesCount > 0 {
|
||||
await updateDownloadProgress(bytes: Int64(bytesCount))
|
||||
// Write remaining buffer
|
||||
if !buffer.isEmpty {
|
||||
try fileHandle.write(contentsOf: buffer)
|
||||
await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count))
|
||||
}
|
||||
|
||||
ModelDownloadLogger.info("Chunk \(chunk.index) downloaded successfully")
|
||||
|
@ -498,9 +542,15 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
let destination = URL(fileURLWithPath: task.destinationPath)
|
||||
.appendingPathComponent(file.name.sanitizedPath)
|
||||
|
||||
let modelHash = repoPath.hash
|
||||
let fileHash = file.path.hash
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
let modelHash = repoPath.stableHash
|
||||
let fileHash = file.path.stableHash
|
||||
|
||||
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true)
|
||||
|
||||
try? fileManager.createDirectory(at: downloadsURL, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let tempURL = downloadsURL
|
||||
.appendingPathComponent("model_\(modelHash)_file_\(fileHash)_\(file.name.sanitizedPath).tmp")
|
||||
|
||||
var lastError: Error?
|
||||
|
@ -534,27 +584,32 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
}
|
||||
|
||||
var downloadedBytes: Int64 = resumeOffset
|
||||
var bytesCount = 0
|
||||
var buffer = Data()
|
||||
buffer.reserveCapacity(1024 * 1024) // Reserve 1MB buffer
|
||||
|
||||
for try await byte in asyncBytes {
|
||||
if isCancelled { throw ModelScopeError.downloadCancelled }
|
||||
|
||||
try fileHandle.write(contentsOf: [byte])
|
||||
downloadedBytes += 1
|
||||
bytesCount += 1
|
||||
buffer.append(byte)
|
||||
|
||||
// Update progress less frequently
|
||||
if bytesCount >= 64 * 1024 {
|
||||
await updateDownloadProgress(bytes: Int64(bytesCount))
|
||||
bytesCount = 0
|
||||
// Write in larger chunks to reduce I/O operations
|
||||
if buffer.count >= 256 * 1024 { // 256KB chunks
|
||||
try fileHandle.write(contentsOf: buffer)
|
||||
downloadedBytes += Int64(buffer.count)
|
||||
await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count))
|
||||
buffer.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
if bytesCount > 0 {
|
||||
await updateDownloadProgress(bytes: Int64(bytesCount))
|
||||
// Write remaining buffer
|
||||
if !buffer.isEmpty {
|
||||
try fileHandle.write(contentsOf: buffer)
|
||||
downloadedBytes += Int64(buffer.count)
|
||||
await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count))
|
||||
}
|
||||
|
||||
// Progress already updated in the loop above
|
||||
|
||||
// Move to final destination
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
|
@ -658,13 +713,42 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|||
|
||||
private func markFileCompleted(task: DownloadTask) async {
|
||||
progress.completedFiles += 1
|
||||
|
||||
// Mark file as completed in progress tracking
|
||||
if var fileProgress = progress.fileProgress[task.file.name] {
|
||||
fileProgress.downloadedBytes = fileProgress.totalBytes
|
||||
fileProgress.isCompleted = true
|
||||
progress.fileProgress[task.file.name] = fileProgress
|
||||
}
|
||||
|
||||
storage.saveFileStatus(task.file, at: task.destinationPath)
|
||||
ModelDownloadLogger.info("Completed: \(task.file.name) (\(progress.completedFiles)/\(progress.totalFiles))")
|
||||
|
||||
await updateProgress(progress.progress)
|
||||
}
|
||||
|
||||
private func updateDownloadProgress(bytes: Int64) async {
|
||||
progress.downloadedBytes += bytes
|
||||
await updateProgress(progress.progress)
|
||||
private func updateFileProgress(fileName: String, bytes: Int64) async {
|
||||
if var fileProgress = progress.fileProgress[fileName] {
|
||||
fileProgress.downloadedBytes = min(fileProgress.downloadedBytes + bytes, fileProgress.totalBytes)
|
||||
progress.fileProgress[fileName] = fileProgress
|
||||
|
||||
let newProgress = progress.progress
|
||||
let progressDiff = abs(newProgress - progress.lastReportedProgress)
|
||||
if progressDiff >= 0.001 || newProgress >= 1.0 {
|
||||
progress.lastReportedProgress = newProgress
|
||||
await updateProgress(newProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func initializeFileProgress(fileName: String, totalBytes: Int64) async {
|
||||
let fileProgress = FileDownloadProgress(
|
||||
fileName: fileName,
|
||||
totalBytes: totalBytes,
|
||||
downloadedBytes: 0,
|
||||
isCompleted: false
|
||||
)
|
||||
progress.fileProgress[fileName] = fileProgress
|
||||
}
|
||||
|
||||
private func updateProgress(_ value: Double) async {
|
||||
|
|
|
@ -62,7 +62,8 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
// MARK: - Properties
|
||||
|
||||
private let repoPath: String
|
||||
private let session: URLSession
|
||||
private var session: URLSession
|
||||
private let sessionConfig: URLSessionConfiguration
|
||||
private let fileManager: FileManager
|
||||
private let storage: ModelDownloadStorage
|
||||
|
||||
|
@ -72,6 +73,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
private var totalSize: Int64 = 0
|
||||
private var downloadedSize: Int64 = 0
|
||||
private var lastUpdatedBytes: Int64 = 0
|
||||
private var lastReportedProgress: Double = 0.0
|
||||
|
||||
// Download cancellation related properties
|
||||
private var isCancelled: Bool = false
|
||||
|
@ -98,9 +100,17 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
self.repoPath = repoPath
|
||||
self.fileManager = .default
|
||||
self.storage = ModelDownloadStorage()
|
||||
self.sessionConfig = config
|
||||
self.session = URLSession(configuration: config)
|
||||
self.source = source
|
||||
ModelDownloadLogger.isEnabled = enableLogging
|
||||
print("ModelScopeDownloadManager init")
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Clean up session when the manager is deallocated
|
||||
session.invalidateAndCancel()
|
||||
print("ModelScopeDownloadManager deinit")
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
@ -135,6 +145,9 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
progress: ((Double) -> Void)? = nil
|
||||
) async throws {
|
||||
|
||||
// Ensure we have a valid session before starting download
|
||||
ensureValidSession()
|
||||
|
||||
isCancelled = false
|
||||
|
||||
ModelDownloadLogger.info("Starting download for modelId: \(modelId)")
|
||||
|
@ -158,7 +171,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
///
|
||||
/// This method gracefully stops all active downloads, closes file handles,
|
||||
/// and preserves temporary files to enable resume functionality in future attempts.
|
||||
/// The URLSession is invalidated to ensure clean cancellation.
|
||||
/// The URLSession is kept valid to allow future downloads.
|
||||
public func cancelDownload() async {
|
||||
isCancelled = true
|
||||
|
||||
|
@ -167,7 +180,8 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
|
||||
await closeFileHandle()
|
||||
|
||||
session.invalidateAndCancel()
|
||||
// Don't invalidate session to allow future downloads
|
||||
// session.invalidateAndCancel()
|
||||
|
||||
ModelDownloadLogger.info("Download cancelled, temporary files preserved for resume")
|
||||
}
|
||||
|
@ -180,11 +194,25 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
/// - progress: Current progress value (0.0 to 1.0)
|
||||
/// - callback: Progress callback function to invoke on main thread
|
||||
private func updateProgress(_ progress: Double, callback: @escaping (Double) -> Void) {
|
||||
Task { @MainActor in
|
||||
callback(progress)
|
||||
// Only update UI progress if there's a significant change (>0.1%)
|
||||
let progressDiff = abs(progress - lastReportedProgress)
|
||||
if progressDiff >= 0.001 || progress >= 1.0 {
|
||||
lastReportedProgress = progress
|
||||
Task { @MainActor in
|
||||
callback(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures the URLSession is valid by recreating it if necessary
|
||||
///
|
||||
/// This method provides a simple and reliable way to ensure we have a valid URLSession
|
||||
/// by always creating a fresh session before downloads. This prevents any potential
|
||||
/// "Task created in a session that has been invalidated" errors.
|
||||
private func ensureValidSession() {
|
||||
session = URLSession(configuration: sessionConfig)
|
||||
}
|
||||
|
||||
/// Fetches the complete file list from ModelScope or Modeler repository
|
||||
///
|
||||
/// This method queries the repository API to discover all available files,
|
||||
|
@ -215,28 +243,28 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Downloads a single file with intelligent resume and retry mechanisms
|
||||
*
|
||||
* This method handles individual file downloads with comprehensive error recovery,
|
||||
* resume functionality through temporary files, and progress tracking. It supports
|
||||
* both ModelScope and Modeler platforms with platform-specific URL construction.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic resume from temporary files using HTTP Range requests
|
||||
* - Exponential backoff retry mechanism (configurable attempts)
|
||||
* - Memory-efficient streaming using URLSession.bytes
|
||||
* - File integrity validation using size verification
|
||||
* - Progress update throttling to prevent UI blocking
|
||||
* - Graceful cancellation with state preservation
|
||||
*
|
||||
* @param file ModelFile metadata including path, size, and download information
|
||||
* @param destinationPath Target local path for the downloaded file
|
||||
* @param onProgress Progress callback receiving downloaded bytes count
|
||||
* @param maxRetries Maximum number of retry attempts (default: 3)
|
||||
* @param retryDelay Delay between retry attempts in seconds (default: 2.0)
|
||||
* @throws ModelScopeError if download fails after all retry attempts
|
||||
*/
|
||||
/// Downloads a single file with intelligent resume and retry mechanisms.
|
||||
///
|
||||
/// This method handles individual file downloads with comprehensive error recovery,
|
||||
/// resume functionality through temporary files, and progress tracking. It supports
|
||||
/// both ModelScope and Modeler platforms with platform-specific URL construction.
|
||||
///
|
||||
/// - Features:
|
||||
/// - Automatic resume from temporary files using HTTP Range requests
|
||||
/// - Exponential backoff retry mechanism (configurable attempts)
|
||||
/// - Memory-efficient streaming using URLSession.bytes
|
||||
/// - File integrity validation using size verification
|
||||
/// - Progress update throttling to prevent UI blocking
|
||||
/// - Graceful cancellation with state preservation
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - file: ModelFile metadata including path, size, and download information.
|
||||
/// - destinationPath: Target local path for the downloaded file.
|
||||
/// - onProgress: Progress callback receiving downloaded bytes count.
|
||||
/// - maxRetries: Maximum number of retry attempts. Defaults to 3.
|
||||
/// - retryDelay: Delay between retry attempts in seconds. Defaults to 2.0.
|
||||
///
|
||||
/// - Throws: `ModelScopeError` if download fails after all retry attempts.
|
||||
private func downloadFile(
|
||||
file: ModelFile,
|
||||
destinationPath: String,
|
||||
|
@ -293,9 +321,15 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
let destination = URL(fileURLWithPath: destinationPath)
|
||||
.appendingPathComponent(file.name.sanitizedPath)
|
||||
|
||||
let modelHash = repoPath.hash
|
||||
let fileHash = file.path.hash
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
let modelHash = repoPath.stableHash
|
||||
let fileHash = file.path.stableHash
|
||||
|
||||
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true)
|
||||
|
||||
try? fileManager.createDirectory(at: downloadsURL, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
let tempURL = downloadsURL
|
||||
.appendingPathComponent("model_\(modelHash)_file_\(fileHash)_\(file.name.sanitizedPath).tmp")
|
||||
|
||||
var resumeOffset: Int64 = 0
|
||||
|
@ -346,7 +380,8 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
}
|
||||
|
||||
var downloadedBytes: Int64 = resumeOffset
|
||||
var bytesCount = 0
|
||||
var buffer = Data()
|
||||
buffer.reserveCapacity(1024 * 1024) // Reserve 1MB buffer
|
||||
|
||||
for try await byte in asyncBytes {
|
||||
// Frequently check cancellation status
|
||||
|
@ -358,17 +393,24 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
return
|
||||
}
|
||||
|
||||
try fileHandle.write(contentsOf: [byte])
|
||||
downloadedBytes += 1
|
||||
bytesCount += 1
|
||||
buffer.append(byte)
|
||||
|
||||
// Reduce progress callback frequency: update every 64KB * 5 instead of every 1KB
|
||||
if bytesCount >= 64 * 1024 * 5 {
|
||||
// Write in larger chunks to reduce I/O operations
|
||||
if buffer.count >= 256 * 1024 { // 256KB chunks
|
||||
try fileHandle.write(contentsOf: buffer)
|
||||
downloadedBytes += Int64(buffer.count)
|
||||
onProgress(downloadedBytes)
|
||||
bytesCount = 0
|
||||
buffer.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Write remaining buffer
|
||||
if !buffer.isEmpty {
|
||||
try fileHandle.write(contentsOf: buffer)
|
||||
downloadedBytes += Int64(buffer.count)
|
||||
onProgress(downloadedBytes)
|
||||
}
|
||||
|
||||
try fileHandle.close()
|
||||
self.currentFileHandle = nil
|
||||
|
||||
|
@ -467,11 +509,13 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
ModelDownloadLogger.debug("Downloading: \(file.name)")
|
||||
|
||||
if !storage.isFileDownloaded(file, at: destinationPath) {
|
||||
let fileStartSize = downloadedSize
|
||||
try await downloadFile(
|
||||
file: file,
|
||||
destinationPath: destinationPath,
|
||||
onProgress: { downloadedBytes in
|
||||
let currentProgress = Double(self.downloadedSize + downloadedBytes) / Double(self.totalSize)
|
||||
// 使用文件开始时的已下载大小 + 当前文件的下载字节数
|
||||
let currentProgress = Double(fileStartSize + downloadedBytes) / Double(self.totalSize)
|
||||
self.updateProgress(currentProgress, callback: progress)
|
||||
},
|
||||
maxRetries: 500, // Can be made configurable
|
||||
|
@ -683,9 +727,12 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol {
|
|||
/// - destinationPath: Destination path used for temp file naming
|
||||
/// - Returns: Size of temporary file in bytes, or 0 if file doesn't exist
|
||||
private func getTempFileSize(for file: ModelFile, destinationPath: String) -> Int64 {
|
||||
let modelHash = repoPath.hash
|
||||
let fileHash = file.path.hash
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
let modelHash = repoPath.stableHash
|
||||
let fileHash = file.path.stableHash
|
||||
|
||||
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true)
|
||||
let tempURL = downloadsURL
|
||||
.appendingPathComponent("model_\(modelHash)_file_\(fileHash)_\(file.name.sanitizedPath).tmp")
|
||||
|
||||
guard fileManager.fileExists(atPath: tempURL.path) else {
|
||||
|
|
|
@ -126,11 +126,14 @@ extension UIImage {
|
|||
}
|
||||
|
||||
UIGraphicsBeginImageContext(self.size)
|
||||
let context = UIGraphicsGetCurrentContext()!
|
||||
guard let context = UIGraphicsGetCurrentContext(), let cgImage = self.cgImage else {
|
||||
UIGraphicsEndImageContext()
|
||||
return self
|
||||
}
|
||||
context.translateBy(x: self.size.width / 2, y: self.size.height / 2)
|
||||
context.rotate(by: angle)
|
||||
context.scaleBy(x: 1.0, y: -1.0)
|
||||
context.draw(self.cgImage!, in: CGRect(x: -self.size.width / 2, y: -self.size.height / 2, width: self.size.width, height: self.size.height))
|
||||
context.draw(cgImage, in: CGRect(x: -self.size.width / 2, y: -self.size.height / 2, width: self.size.width, height: self.size.height))
|
||||
|
||||
let rotatedImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
|
|
|
@ -6,8 +6,23 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
extension String {
|
||||
|
||||
/// Generate a stable hash value for temporary file naming
|
||||
///
|
||||
/// Unlike the hash property in Swift's standard library, this method
|
||||
/// generates the same hash value for identical strings across different
|
||||
/// app launches, ensuring that resumable download functionality works correctly.
|
||||
///
|
||||
/// - Returns: A stable hash value based on SHA256
|
||||
var stableHash: String {
|
||||
let data = self.data(using: .utf8) ?? Data()
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.compactMap { String(format: "%02x", $0) }.joined().prefix(16).description
|
||||
}
|
||||
|
||||
func removingTaobaoPrefix() -> String {
|
||||
return self.replacingOccurrences(of: "taobao-mnn/", with: "")
|
||||
}
|
||||
|
|
|
@ -104,11 +104,9 @@ extension DraftMessage {
|
|||
.asyncMap { (media : Media) -> (Media, URL?, URL?) in
|
||||
(media, await media.getThumbnailURL(), await media.getURL())
|
||||
}
|
||||
.filter { (media: Media, thumb: URL?, full: URL?) -> Bool in
|
||||
thumb != nil && full != nil
|
||||
}
|
||||
.map { media, thumb, full in
|
||||
LLMChatImage(id: media.id.uuidString, thumbnail: thumb!, full: full!)
|
||||
.compactMap { media, thumb, full in
|
||||
guard let thumb, let full else { return nil }
|
||||
return LLMChatImage(id: media.id.uuidString, thumbnail: thumb, full: full)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,11 +116,9 @@ extension DraftMessage {
|
|||
.asyncMap { (media : Media) -> (Media, URL?, URL?) in
|
||||
(media, await media.getThumbnailURL(), await media.getURL())
|
||||
}
|
||||
.filter { (media: Media, thumb: URL?, full: URL?) -> Bool in
|
||||
thumb != nil && full != nil
|
||||
}
|
||||
.map { media, thumb, full in
|
||||
LLMChatVideo(id: media.id.uuidString, thumbnail: thumb!, full: full!)
|
||||
.compactMap { media, thumb, full in
|
||||
guard let thumb, let full else { return nil }
|
||||
return LLMChatVideo(id: media.id.uuidString, thumbnail: thumb, full: full)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -63,15 +63,22 @@
|
|||
2. 编译 MNN.framework:
|
||||
|
||||
```shell
|
||||
cd MNN/
|
||||
sh package_scripts/ios/buildiOS.sh "-DMNN_ARM82=true -DMNN_LOW_MEMORY=true -DMNN_SUPPORT_TRANSFORMER_FUSE=true -DMNN_BUILD_LLM=true -DMNN_CPU_WEIGHT_DEQUANT_GEMM=true
|
||||
sh package_scripts/ios/buildiOS.sh "
|
||||
-DMNN_ARM82=ON
|
||||
-DMNN_LOW_MEMORY=ON
|
||||
-DMNN_SUPPORT_TRANSFORMER_FUSE=ON
|
||||
-DMNN_BUILD_LLM=ON
|
||||
-DMNN_CPU_WEIGHT_DEQUANT_GEMM=ON
|
||||
-DMNN_METAL=ON
|
||||
-DMNN_BUILD_DIFFUSION=ON
|
||||
-DMNN_BUILD_OPENCV=ON
|
||||
-DMNN_IMGCODECS=ON
|
||||
-DMNN_OPENCL=OFF
|
||||
-DMNN_SEP_BUILD=OFF
|
||||
-DMNN_SUPPORT_TRANSFORMER_FUSE=ON"
|
||||
-DLLM_SUPPORT_AUDIO=ON
|
||||
-DMNN_BUILD_AUDIO=ON
|
||||
-DLLM_SUPPORT_VISION=ON
|
||||
-DMNN_BUILD_OPENCV=ON
|
||||
-DMNN_IMGCODECS=ON
|
||||
"
|
||||
```
|
||||
|
||||
3. 拷贝 framework 到 iOS 项目中
|
||||
|
|
Loading…
Reference in New Issue