refactor: restructure API models and services for reading runtime
This commit is contained in:
parent
3cf95f1ee1
commit
13688f4290
@ -6,8 +6,19 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
7A1B2C3D4E5F60718293A40C /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 05F6CD132FA886330043A7BC /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 05F6CD1A2FA886330043A7BC;
|
||||||
|
remoteInfo = AIStudyApp;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
05F6CD1B2FA886330043A7BC /* AIStudyApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AIStudyApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
05F6CD1B2FA886330043A7BC /* AIStudyApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AIStudyApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
7A1B2C3D4E5F60718293A401 /* AIStudyAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AIStudyAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@ -16,6 +27,11 @@
|
|||||||
path = AIStudyApp;
|
path = AIStudyApp;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
7A1B2C3D4E5F60718293A402 /* AIStudyAppReaderTests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = AIStudyAppReaderTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -26,6 +42,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
7A1B2C3D4E5F60718293A403 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@ -33,6 +56,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
05F6CD1D2FA886330043A7BC /* AIStudyApp */,
|
05F6CD1D2FA886330043A7BC /* AIStudyApp */,
|
||||||
|
7A1B2C3D4E5F60718293A402 /* AIStudyAppReaderTests */,
|
||||||
05F6CD1C2FA886330043A7BC /* Products */,
|
05F6CD1C2FA886330043A7BC /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -41,6 +65,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
05F6CD1B2FA886330043A7BC /* AIStudyApp.app */,
|
05F6CD1B2FA886330043A7BC /* AIStudyApp.app */,
|
||||||
|
7A1B2C3D4E5F60718293A401 /* AIStudyAppTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -70,6 +95,29 @@
|
|||||||
productReference = 05F6CD1B2FA886330043A7BC /* AIStudyApp.app */;
|
productReference = 05F6CD1B2FA886330043A7BC /* AIStudyApp.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
7A1B2C3D4E5F60718293A404 /* AIStudyAppTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 7A1B2C3D4E5F60718293A40A /* Build configuration list for PBXNativeTarget "AIStudyAppTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
7A1B2C3D4E5F60718293A405 /* Sources */,
|
||||||
|
7A1B2C3D4E5F60718293A403 /* Frameworks */,
|
||||||
|
7A1B2C3D4E5F60718293A406 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
7A1B2C3D4E5F60718293A40B /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
7A1B2C3D4E5F60718293A402 /* AIStudyAppReaderTests */,
|
||||||
|
);
|
||||||
|
name = AIStudyAppTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = AIStudyAppTests;
|
||||||
|
productReference = 7A1B2C3D4E5F60718293A401 /* AIStudyAppTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@ -83,6 +131,10 @@
|
|||||||
05F6CD1A2FA886330043A7BC = {
|
05F6CD1A2FA886330043A7BC = {
|
||||||
CreatedOnToolsVersion = 26.4.1;
|
CreatedOnToolsVersion = 26.4.1;
|
||||||
};
|
};
|
||||||
|
7A1B2C3D4E5F60718293A404 = {
|
||||||
|
CreatedOnToolsVersion = 26.4.1;
|
||||||
|
TestTargetID = 05F6CD1A2FA886330043A7BC;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 05F6CD162FA886330043A7BC /* Build configuration list for PBXProject "AIStudyApp" */;
|
buildConfigurationList = 05F6CD162FA886330043A7BC /* Build configuration list for PBXProject "AIStudyApp" */;
|
||||||
@ -102,6 +154,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
05F6CD1A2FA886330043A7BC /* AIStudyApp */,
|
05F6CD1A2FA886330043A7BC /* AIStudyApp */,
|
||||||
|
7A1B2C3D4E5F60718293A404 /* AIStudyAppTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@ -114,6 +167,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
7A1B2C3D4E5F60718293A406 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@ -124,8 +184,23 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
7A1B2C3D4E5F60718293A405 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
7A1B2C3D4E5F60718293A40B /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 05F6CD1A2FA886330043A7BC /* AIStudyApp */;
|
||||||
|
targetProxy = 7A1B2C3D4E5F60718293A40C /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
05F6CD242FA886350043A7BC /* Debug */ = {
|
05F6CD242FA886350043A7BC /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@ -258,6 +333,7 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
HEADER_SEARCH_PATHS = (
|
HEADER_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/AIStudyApp/Core/Services",
|
"$(PROJECT_DIR)/AIStudyApp/Core/Services",
|
||||||
@ -270,6 +346,8 @@
|
|||||||
LIBRARY_SEARCH_PATHS = (
|
LIBRARY_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework/ios-arm64-simulator",
|
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework/ios-arm64-simulator",
|
||||||
|
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework.bak/ios-arm64",
|
||||||
|
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework.bak/ios-arm64-simulator",
|
||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@ -309,6 +387,7 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
HEADER_SEARCH_PATHS = (
|
HEADER_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/AIStudyApp/Core/Services",
|
"$(PROJECT_DIR)/AIStudyApp/Core/Services",
|
||||||
@ -321,6 +400,8 @@
|
|||||||
LIBRARY_SEARCH_PATHS = (
|
LIBRARY_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework/ios-arm64-simulator",
|
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework/ios-arm64-simulator",
|
||||||
|
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework.bak/ios-arm64",
|
||||||
|
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework.bak/ios-arm64-simulator",
|
||||||
);
|
);
|
||||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@ -347,6 +428,63 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
7A1B2C3D4E5F60718293A407 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 88FMP9VK6T;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cloud.longde.AIStudyAppTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/AIStudyApp/Core/Services";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AIStudyApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AIStudyApp";
|
||||||
|
TEST_TARGET_NAME = AIStudyApp;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
7A1B2C3D4E5F60718293A408 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 88FMP9VK6T;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cloud.longde.AIStudyAppTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/AIStudyApp/Core/Services";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AIStudyApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AIStudyApp";
|
||||||
|
TEST_TARGET_NAME = AIStudyApp;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@ -368,6 +506,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
7A1B2C3D4E5F60718293A40A /* Build configuration list for PBXNativeTarget "AIStudyAppTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7A1B2C3D4E5F60718293A407 /* Debug */,
|
||||||
|
7A1B2C3D4E5F60718293A408 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = 05F6CD132FA886330043A7BC /* Project object */;
|
rootObject = 05F6CD132FA886330043A7BC /* Project object */;
|
||||||
|
|||||||
Binary file not shown.
@ -29,6 +29,17 @@
|
|||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
<Testables>
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7A1B2C3D4E5F60718293A404"
|
||||||
|
BuildableName = "AIStudyAppTests.xctest"
|
||||||
|
BlueprintName = "AIStudyAppTests"
|
||||||
|
ReferencedContainer = "container:AIStudyApp.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
|
|||||||
@ -1,30 +1,14 @@
|
|||||||
//
|
//
|
||||||
// APIModels.swift - 对齐 api-server 实际返回结构
|
// APIModels.swift — 未拆分到独立文件的类型
|
||||||
|
//
|
||||||
|
// 已迁移到独立文件的类型:
|
||||||
|
// Common.swift, Auth.swift, User.swift, KnowledgeBase.swift,
|
||||||
|
// KnowledgeSource.swift, KnowledgeItem.swift, Activity.swift,
|
||||||
|
// FocusItem.swift, Reading.swift, Quiz.swift, Chat.swift
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - API Envelope (ResponseInterceptor wraps all responses)
|
|
||||||
|
|
||||||
struct APIEnvelope<T: Decodable>: Decodable {
|
|
||||||
let success: Bool
|
|
||||||
let data: T
|
|
||||||
let timestamp: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pagination
|
|
||||||
|
|
||||||
struct PaginationMeta: Codable {
|
|
||||||
let page: Int
|
|
||||||
let limit: Int
|
|
||||||
let total: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PaginatedResponse<T: Decodable>: Decodable {
|
|
||||||
let data: [T]
|
|
||||||
let meta: PaginationMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Waitlist
|
// MARK: - Waitlist
|
||||||
|
|
||||||
struct WaitlistEntry: Codable, Identifiable {
|
struct WaitlistEntry: Codable, Identifiable {
|
||||||
@ -40,194 +24,21 @@ struct WaitlistEntry: Codable, Identifiable {
|
|||||||
|
|
||||||
struct WaitlistCreateRequest: Codable {
|
struct WaitlistCreateRequest: Codable {
|
||||||
let email: String
|
let email: String
|
||||||
let nickname: String?
|
let name: String?
|
||||||
let devices: [String]?
|
|
||||||
let interests: [String]?
|
|
||||||
let painpoint: String?
|
|
||||||
let willingBeta: Bool?
|
|
||||||
|
|
||||||
init(email: String, nickname: String? = nil, devices: [String]? = nil,
|
|
||||||
interests: [String]? = nil, painpoint: String? = nil, willingBeta: Bool = true) {
|
|
||||||
self.email = email
|
|
||||||
self.nickname = nickname
|
|
||||||
self.devices = devices
|
|
||||||
self.interests = interests
|
|
||||||
self.painpoint = painpoint
|
|
||||||
self.willingBeta = willingBeta
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WaitlistResponse: Codable {
|
struct WaitlistResponse: Codable {
|
||||||
let success: Bool?
|
let success: Bool
|
||||||
let message: String?
|
let message: String?
|
||||||
let data: WaitlistEntry?
|
let entry: WaitlistEntry?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WaitlistStats: Codable {
|
struct WaitlistStats: Codable {
|
||||||
let total: Int?
|
let total: Int
|
||||||
let today: Int?
|
let today: Int
|
||||||
let deviceBreakdown: [String: Int]?
|
|
||||||
let interestBreakdown: [String: Int]?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Auth
|
// MARK: - Active Recall
|
||||||
|
|
||||||
struct AuthResponse: Codable {
|
|
||||||
let accessToken: String
|
|
||||||
let refreshToken: String?
|
|
||||||
let user: AuthUser?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AuthUser: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let email: String?
|
|
||||||
let nickname: String?
|
|
||||||
let avatarUrl: String?
|
|
||||||
let role: String?
|
|
||||||
let status: String?
|
|
||||||
let onboardingCompleted: Bool?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppleAuthRequest: Codable {
|
|
||||||
let identityToken: String
|
|
||||||
let authorizationCode: String?
|
|
||||||
let fullName: AppleFullName?
|
|
||||||
let nonce: String?
|
|
||||||
|
|
||||||
struct AppleFullName: Codable {
|
|
||||||
let givenName: String?
|
|
||||||
let familyName: String?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RefreshRequest: Codable {
|
|
||||||
let refreshToken: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - User Profile (matches GET /users/me with include: profile + preferences)
|
|
||||||
|
|
||||||
struct UserProfileResponse: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let email: String?
|
|
||||||
let nickname: String?
|
|
||||||
let avatarUrl: String?
|
|
||||||
let role: String?
|
|
||||||
let status: String?
|
|
||||||
let onboardingCompleted: Bool?
|
|
||||||
let createdAt: String?
|
|
||||||
let profile: UserProfileData?
|
|
||||||
let preferences: UserPreferences?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UserProfileData: Codable {
|
|
||||||
let learningIdentity: String?
|
|
||||||
let learningDirection: String?
|
|
||||||
let bio: String?
|
|
||||||
let currentGoal: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UserPreferences: Codable, Equatable {
|
|
||||||
let preferredMethods: [String]?
|
|
||||||
let defaultFocusMinutes: Int?
|
|
||||||
let aiSuggestionLevel: String?
|
|
||||||
let language: String?
|
|
||||||
let appearance: String?
|
|
||||||
let notificationEnabled: Bool?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UpdateProfileRequest: Codable {
|
|
||||||
let nickname: String?
|
|
||||||
let avatarUrl: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UpdatePreferencesRequest: Codable {
|
|
||||||
let preferredMethods: [String]?
|
|
||||||
let defaultFocusMinutes: Int?
|
|
||||||
let aiSuggestionLevel: String?
|
|
||||||
let language: String?
|
|
||||||
let appearance: String?
|
|
||||||
let notificationEnabled: Bool?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UpdateProfileDataRequest: Codable {
|
|
||||||
let learningIdentity: String?
|
|
||||||
let learningDirection: String?
|
|
||||||
let bio: String?
|
|
||||||
let currentGoal: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Knowledge Base (matches Prisma KnowledgeBase model)
|
|
||||||
|
|
||||||
struct KnowledgeBase: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let userId: String?
|
|
||||||
let title: String
|
|
||||||
let description: String?
|
|
||||||
let coverKey: String?
|
|
||||||
let coverUrl: String?
|
|
||||||
let coverType: String?
|
|
||||||
let visibility: String?
|
|
||||||
let isPinned: Bool?
|
|
||||||
let ownerType: String?
|
|
||||||
let isVerified: Bool?
|
|
||||||
let status: String?
|
|
||||||
let itemCount: Int?
|
|
||||||
let lastStudiedAt: String?
|
|
||||||
let createdAt: String?
|
|
||||||
let updatedAt: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CreateKnowledgeBaseRequest: Codable {
|
|
||||||
let title: String
|
|
||||||
let description: String?
|
|
||||||
var coverKey: String? = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
struct KnowledgeBaseCoverUploadResult {
|
|
||||||
let fileId: String
|
|
||||||
let objectKey: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Knowledge Items (matches Prisma KnowledgeItem model)
|
|
||||||
|
|
||||||
|
|
||||||
struct GenerateKnowledgeResponse: Codable {
|
|
||||||
let items: [KnowledgeItem]
|
|
||||||
let count: Int
|
|
||||||
let sourceId: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct KnowledgeItem: Codable, Identifiable, Hashable {
|
|
||||||
let id: String
|
|
||||||
let userId: String?
|
|
||||||
let knowledgeBaseId: String?
|
|
||||||
let parentId: String?
|
|
||||||
let itemType: String?
|
|
||||||
let title: String
|
|
||||||
let content: String?
|
|
||||||
let summary: String?
|
|
||||||
let sourceType: String?
|
|
||||||
let sourceRef: String?
|
|
||||||
let orderIndex: Int?
|
|
||||||
let status: String?
|
|
||||||
let createdAt: String?
|
|
||||||
let updatedAt: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CreateKnowledgeItemRequest: Codable {
|
|
||||||
let knowledgeBaseId: String
|
|
||||||
let title: String
|
|
||||||
let content: String?
|
|
||||||
let itemType: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UpdateKnowledgeItemRequest: Codable {
|
|
||||||
let title: String?
|
|
||||||
let content: String?
|
|
||||||
let summary: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Active Recall (matches ActiveRecallQuestion / Answer models)
|
|
||||||
|
|
||||||
struct ActiveRecallQuestion: Codable, Identifiable {
|
struct ActiveRecallQuestion: Codable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
@ -252,7 +63,7 @@ struct SubmitAnswerRequest: Codable {
|
|||||||
let answerText: String
|
let answerText: String
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AI Analysis (matches AiAnalysisResult model)
|
// MARK: - AI Analysis
|
||||||
|
|
||||||
struct AIAnalysisRequest: Codable {
|
struct AIAnalysisRequest: Codable {
|
||||||
let questionText: String?
|
let questionText: String?
|
||||||
@ -265,23 +76,23 @@ struct AIAnalysisRequest: Codable {
|
|||||||
struct AIAnalysisResult: Codable, Identifiable {
|
struct AIAnalysisResult: Codable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
let userId: String?
|
let userId: String?
|
||||||
let summary: String?
|
let knowledgeItemId: String?
|
||||||
|
let questionText: String?
|
||||||
|
let userAnswer: String?
|
||||||
|
let analysis: String?
|
||||||
|
let score: Int?
|
||||||
|
let feedback: String?
|
||||||
let masteryScore: Int?
|
let masteryScore: Int?
|
||||||
let strengths: [String]?
|
|
||||||
let weaknesses: [String]?
|
|
||||||
let suggestions: [String]?
|
|
||||||
let nextActions: [String]?
|
|
||||||
let rawResult: AIAnalysisRawResult?
|
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AIAnalysisRawResult: Codable {
|
struct AIAnalysisRawResult: Codable {
|
||||||
let score: Double?
|
let id: String
|
||||||
let analysis: String?
|
let resultJson: String?
|
||||||
let focusItems: [String]?
|
let createdAt: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Learning Session (matches Prisma LearningSession model)
|
// MARK: - Learning Session
|
||||||
|
|
||||||
struct LearningSession: Codable, Identifiable {
|
struct LearningSession: Codable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
@ -294,7 +105,7 @@ struct LearningSession: Codable, Identifiable {
|
|||||||
let endedAt: String?
|
let endedAt: String?
|
||||||
let durationSeconds: Int?
|
let durationSeconds: Int?
|
||||||
let focusMinutes: Int?
|
let focusMinutes: Int?
|
||||||
let createdAt: String?
|
let totalActiveSeconds: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CreateLearningSessionRequest: Codable {
|
struct CreateLearningSessionRequest: Codable {
|
||||||
@ -303,270 +114,101 @@ struct CreateLearningSessionRequest: Codable {
|
|||||||
let mode: String?
|
let mode: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Review (matches ReviewCard / ReviewLog models)
|
// MARK: - Review
|
||||||
|
|
||||||
struct ReviewCard: Codable, Identifiable {
|
struct ReviewCard: Codable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
let userId: String?
|
let userId: String?
|
||||||
let knowledgeItemId: String?
|
let knowledgeItemId: String?
|
||||||
let frontText: String
|
let frontText: String?
|
||||||
let backText: String?
|
let backText: String?
|
||||||
let difficulty: String?
|
let difficulty: String?
|
||||||
let status: String?
|
let status: String?
|
||||||
let nextReviewAt: String?
|
let scheduleState: String?
|
||||||
let intervalDays: Int?
|
let intervalDays: Int?
|
||||||
let easeFactor: Double?
|
let ease: Double?
|
||||||
let repetitionCount: Int?
|
let nextReviewAt: String?
|
||||||
let lapseCount: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SubmitReviewRequest: Codable {
|
|
||||||
let rating: String
|
|
||||||
let responseText: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Focus Items (matches Prisma FocusItem model)
|
|
||||||
|
|
||||||
struct FocusItem: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let userId: String?
|
|
||||||
let knowledgeBaseId: String?
|
|
||||||
let knowledgeItemId: String?
|
|
||||||
let title: String
|
|
||||||
let reason: String?
|
|
||||||
let suggestion: String?
|
|
||||||
let priority: String?
|
|
||||||
let status: String?
|
|
||||||
let masteryScore: Int?
|
|
||||||
let dueAt: String?
|
|
||||||
let completedAt: String?
|
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Activity (matches DailyLearningActivity + summary aggregation)
|
struct SubmitReviewRequest: Codable {
|
||||||
|
let difficulty: String?
|
||||||
struct ActivitySummary: Codable {
|
|
||||||
let totalMinutes: Int?
|
|
||||||
let totalCardsReviewed: Int?
|
|
||||||
let activeDays: Int?
|
|
||||||
let dailyAverage: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ActivityHeatmap: Codable {
|
|
||||||
// Dictionary of "YYYY-MM-DD" -> durationSeconds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Feedback
|
// MARK: - Feedback
|
||||||
|
|
||||||
struct FeedbackCreateRequest: Codable {
|
struct FeedbackCreateRequest: Codable {
|
||||||
let category: String
|
let category: String?
|
||||||
let content: String
|
let content: String
|
||||||
let email: String?
|
let email: String?
|
||||||
|
|
||||||
init(category: String = "general", content: String, email: String? = nil) {
|
|
||||||
self.category = category
|
|
||||||
self.content = content
|
|
||||||
self.email = email
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FeedbackData: Codable, Identifiable {
|
struct FeedbackData: Codable, Identifiable {
|
||||||
let id: String?
|
let id: String
|
||||||
|
let userId: String?
|
||||||
let category: String?
|
let category: String?
|
||||||
let content: String?
|
let content: String?
|
||||||
let status: String?
|
let status: String?
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notifications (matches Prisma Notification model)
|
// MARK: - Notifications
|
||||||
|
|
||||||
struct NotificationItem: Codable, Identifiable {
|
struct NotificationItem: Codable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
let userId: String?
|
let userId: String?
|
||||||
let type: String
|
let type: String?
|
||||||
let title: String
|
let title: String
|
||||||
let content: String?
|
let content: String?
|
||||||
let data: [String: String]?
|
|
||||||
let readAt: String?
|
let readAt: String?
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Generic
|
// MARK: - Quiz (extras)
|
||||||
|
|
||||||
struct GenericSuccessResponse: Codable {
|
|
||||||
let success: Bool?
|
|
||||||
let message: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BatchDeleteResponse: Codable {
|
|
||||||
let success: Bool?
|
|
||||||
let message: String?
|
|
||||||
let count: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Quiz
|
|
||||||
|
|
||||||
struct Quiz: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let knowledgeBaseId: String?
|
|
||||||
let title: String?
|
|
||||||
let description: String?
|
|
||||||
let questionCount: Int?
|
|
||||||
let sourceType: String?
|
|
||||||
let status: String?
|
|
||||||
let createdAt: String?
|
|
||||||
let questions: [QuizQuestion]?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct QuizQuestion: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let quizId: String?
|
|
||||||
let type: String?
|
|
||||||
let stem: String?
|
|
||||||
let options: [String]?
|
|
||||||
let answer: String?
|
|
||||||
let explanation: String?
|
|
||||||
let orderIndex: Int?
|
|
||||||
|
|
||||||
/// M-QUIZ-01-CLEANUP-02: 安全题型解析——未知 type → nil
|
|
||||||
var questionType: QuizQuestionType? { QuizQuestionType.from(rawValue: type) }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct QuizAttempt: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let quizId: String?
|
|
||||||
let totalQuestions: Int?
|
|
||||||
let correctCount: Int?
|
|
||||||
let score: Int?
|
|
||||||
let startedAt: String?
|
|
||||||
let finishedAt: String?
|
|
||||||
let answers: [QuizAnswer]?
|
|
||||||
let quiz: QuizInfo?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct QuizInfo: Codable { let title: String? }
|
struct QuizInfo: Codable { let title: String? }
|
||||||
|
|
||||||
struct QuizAnswer: Codable, Identifiable {
|
struct QuizAnswer: Codable, Identifiable {
|
||||||
let id: String
|
let id: String; let questionId: String?; let answer: String?
|
||||||
let attemptId: String?
|
|
||||||
let questionId: String?
|
|
||||||
let userAnswer: String?
|
|
||||||
let isCorrect: Bool?
|
|
||||||
let answeredAt: String?
|
|
||||||
let question: QuizQuestion?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct QuizGenerateRequest: Codable {
|
struct QuizGenerateRequest: Codable {
|
||||||
let knowledgeBaseId: String
|
let knowledgeBaseId: String
|
||||||
|
let sourceType: String?
|
||||||
|
let sourceId: String?
|
||||||
let questionCount: Int
|
let questionCount: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
struct QuizSubmitRequest: Codable {
|
struct QuizSubmitRequest: Codable {
|
||||||
let attemptId: String
|
let attemptId: String; let answers: [QuizAnswerItem]
|
||||||
let answers: [QuizAnswerItem]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct QuizAnswerItem: Codable {
|
struct QuizAnswerItem: Codable {
|
||||||
let questionId: String
|
let questionId: String; let answer: String
|
||||||
let answer: String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct QuizSubmitResponse: Codable {
|
struct QuizSubmitResponse: Codable {
|
||||||
let score: Int?
|
let score: Int?; let totalQuestions: Int?; let correctCount: Int?
|
||||||
let correctCount: Int?
|
|
||||||
let totalQuestions: Int?
|
|
||||||
let finishedAt: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// M-QUIZ-01-03: Attempt 题目(不含 answer/explanation 直到提交后)
|
|
||||||
struct QuizAttemptStartResponse: Codable {
|
struct QuizAttemptStartResponse: Codable {
|
||||||
let id: String
|
let id: String; let quizId: String?; let status: String?
|
||||||
let quizId: String?
|
|
||||||
let totalQuestions: Int?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - RAG Chat
|
// MARK: - Chat (extras)
|
||||||
|
|
||||||
// MARK: - Chat Scope
|
|
||||||
|
|
||||||
enum ChatScopeType: String, Codable {
|
|
||||||
case knowledgeBase = "knowledge_base"
|
|
||||||
case folder = "folder"
|
|
||||||
case material = "material"
|
|
||||||
case knowledgeItem = "knowledge_item"
|
|
||||||
case global = "global"
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChatEntryContext: Hashable {
|
|
||||||
let scopeType: ChatScopeType
|
|
||||||
let scopeId: String?
|
|
||||||
let scopeName: String
|
|
||||||
let parentKnowledgeBaseId: String?
|
|
||||||
let createdFrom: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChatSession: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let userId: String?
|
|
||||||
let knowledgeBaseId: String?
|
|
||||||
let scopeType: String?
|
|
||||||
let scopeId: String?
|
|
||||||
let parentKnowledgeBaseId: String?
|
|
||||||
let title: String?
|
|
||||||
let createdFrom: String?
|
|
||||||
let isPinned: Bool?
|
|
||||||
let isArchived: Bool?
|
|
||||||
let isDeleted: Bool?
|
|
||||||
let modelMode: String?
|
|
||||||
let modelId: String?
|
|
||||||
let lastMessageAt: String?
|
|
||||||
let createdAt: String?
|
|
||||||
let updatedAt: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChatMessage: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let sessionId: String?
|
|
||||||
let role: String
|
|
||||||
let content: String
|
|
||||||
let tokens: Int?
|
|
||||||
let scopeSnapshot: ChatScopeSnapshot?
|
|
||||||
let createdAt: String?
|
|
||||||
let citations: [ChatCitation]?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChatScopeSnapshot: Codable {
|
struct ChatScopeSnapshot: Codable {
|
||||||
let scopeType: String?
|
let scopeType: String?
|
||||||
let scopeId: String?
|
let scopeId: String?
|
||||||
let parentKnowledgeBaseId: String?
|
let parentKnowledgeBaseId: String?
|
||||||
}
|
let type: String?
|
||||||
|
let id: String?
|
||||||
struct ChatCitation: Codable, Identifiable {
|
let name: String?
|
||||||
let id: String
|
|
||||||
let chunkId: String?
|
|
||||||
let sourceId: String?
|
|
||||||
let sourceTitle: String?
|
|
||||||
let excerptText: String?
|
|
||||||
let pageNumber: Int?
|
|
||||||
let lineStart: Int?
|
|
||||||
let lineEnd: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CreateSessionRequest: Codable {
|
|
||||||
let scopeType: String
|
|
||||||
let scopeId: String?
|
|
||||||
let parentKnowledgeBaseId: String?
|
|
||||||
let createdFrom: String
|
|
||||||
let title: String?
|
|
||||||
let forceCreate: Bool?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UpdateChatSessionRequest: Codable {
|
struct UpdateChatSessionRequest: Codable {
|
||||||
var title: String?
|
let title: String?
|
||||||
var isPinned: Bool?
|
let isPinned: Bool?
|
||||||
var isArchived: Bool?
|
let isArchived: Bool?
|
||||||
var modelMode: String?
|
|
||||||
var modelId: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SendMessageRequest: Codable {
|
struct SendMessageRequest: Codable {
|
||||||
@ -574,55 +216,15 @@ struct SendMessageRequest: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct SendMessageResponse: Codable {
|
struct SendMessageResponse: Codable {
|
||||||
let id: String?
|
let messageId: String?
|
||||||
let role: String?
|
let sessionId: String?
|
||||||
let content: String?
|
|
||||||
let tokens: Int?
|
|
||||||
let blocked: Bool?
|
|
||||||
let message: ChatMessage?
|
|
||||||
let citations: [ChatCitation]?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Document Import
|
// MARK: - Document Import
|
||||||
|
|
||||||
struct CreateImportRequest: Codable {
|
|
||||||
let knowledgeBaseId: String
|
|
||||||
let fileName: String?
|
|
||||||
let sourceType: String?
|
|
||||||
let rawText: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ImportStatusResponse: Codable {
|
|
||||||
let id: String
|
|
||||||
let jobId: String?
|
|
||||||
let status: String?
|
|
||||||
let fileName: String?
|
|
||||||
let knowledgeBaseId: String?
|
|
||||||
let errorMessage: String?
|
|
||||||
let progress: Int?
|
|
||||||
let itemCount: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ParseResponse: Codable {
|
struct ParseResponse: Codable {
|
||||||
let id: String?
|
let text: String?
|
||||||
let jobId: String?
|
let textLength: Int?
|
||||||
let status: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Import Candidate
|
|
||||||
|
|
||||||
struct ImportCandidate: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let sourceId: String?
|
|
||||||
let title: String?
|
|
||||||
let content: String?
|
|
||||||
let status: String?
|
|
||||||
let confidence: Double?
|
|
||||||
let createdAt: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BatchAcceptRequest: Codable {
|
|
||||||
let sourceId: String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Knowledge Folder
|
// MARK: - Knowledge Folder
|
||||||
@ -630,11 +232,10 @@ struct BatchAcceptRequest: Codable {
|
|||||||
struct KnowledgeFolder: Codable, Identifiable {
|
struct KnowledgeFolder: Codable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
let knowledgeBaseId: String?
|
let knowledgeBaseId: String?
|
||||||
let parentId: String?
|
|
||||||
let name: String
|
let name: String
|
||||||
let sortOrder: Int?
|
let parentId: String?
|
||||||
|
let orderIndex: Int?
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
let updatedAt: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CreateFolderRequest: Codable {
|
struct CreateFolderRequest: Codable {
|
||||||
@ -642,54 +243,6 @@ struct CreateFolderRequest: Codable {
|
|||||||
let parentId: String?
|
let parentId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Knowledge Source
|
|
||||||
|
|
||||||
struct KnowledgeSource: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let knowledgeBaseId: String?
|
|
||||||
let fileId: String?
|
|
||||||
let title: String?
|
|
||||||
let originalFilename: String?
|
|
||||||
let type: String?
|
|
||||||
let mimeType: String?
|
|
||||||
let parseStatus: String?
|
|
||||||
let indexStatus: String?
|
|
||||||
let textLength: Int?
|
|
||||||
let createdAt: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AddSourceRequest: Codable {
|
|
||||||
let fileId: String?
|
|
||||||
let type: String?
|
|
||||||
let title: String?
|
|
||||||
let originalFilename: String?
|
|
||||||
let mimeType: String?
|
|
||||||
let sizeBytes: Int?
|
|
||||||
let originalObjectKey: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Learning Activity
|
|
||||||
|
|
||||||
struct ActivityTrend: Codable {
|
|
||||||
let date: String?
|
|
||||||
let value: Double?
|
|
||||||
let label: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ActivityStreak: Codable {
|
|
||||||
let currentStreak: Int?
|
|
||||||
let longestStreak: Int?
|
|
||||||
let lastActiveDate: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ActivityRecommendation: Codable, Identifiable {
|
|
||||||
var id: String { title ?? UUID().uuidString }
|
|
||||||
let title: String?
|
|
||||||
let description: String?
|
|
||||||
let type: String?
|
|
||||||
let priority: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - File Upload (COS presigned URL flow)
|
// MARK: - File Upload (COS presigned URL flow)
|
||||||
|
|
||||||
struct FileUploadUrlRequest: Codable {
|
struct FileUploadUrlRequest: Codable {
|
||||||
@ -699,8 +252,8 @@ struct FileUploadUrlRequest: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct FileUploadUrlResponse: Codable {
|
struct FileUploadUrlResponse: Codable {
|
||||||
let uploadId: String
|
|
||||||
let uploadUrl: String
|
let uploadUrl: String
|
||||||
|
let uploadId: String
|
||||||
let objectKey: String
|
let objectKey: String
|
||||||
let bucket: String
|
let bucket: String
|
||||||
let region: String
|
let region: String
|
||||||
@ -715,22 +268,15 @@ struct FileConfirmUploadRequest: Codable {
|
|||||||
|
|
||||||
struct FileConfirmUploadResponse: Codable {
|
struct FileConfirmUploadResponse: Codable {
|
||||||
let id: String
|
let id: String
|
||||||
let filename: String
|
|
||||||
let objectKey: String
|
|
||||||
let sizeBytes: Int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FileDetailResponse: Codable {
|
struct FileDetailResponse: Codable {
|
||||||
let file: FileInfo
|
|
||||||
let downloadUrl: String
|
let downloadUrl: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FileInfo: Codable {
|
struct FileInfo: Codable {
|
||||||
let id: String
|
let id: String
|
||||||
let filename: String
|
|
||||||
let mimeType: String?
|
|
||||||
let sizeBytes: Int
|
|
||||||
let objectKey: String?
|
let objectKey: String?
|
||||||
let storagePath: String?
|
let url: String?
|
||||||
let createdAt: String?
|
let sizeBytes: Int?
|
||||||
}
|
}
|
||||||
|
|||||||
34
AIStudyApp/AIStudyApp/Core/Models/Activity.swift
Normal file
34
AIStudyApp/AIStudyApp/Core/Models/Activity.swift
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ActivitySummary: Codable {
|
||||||
|
let dailyAverage: Int?
|
||||||
|
let totalMinutes: Int?
|
||||||
|
let totalCardsReviewed: Int?
|
||||||
|
let activeDays: Int?
|
||||||
|
let totalSessions: Int?
|
||||||
|
let totalEvents: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActivityHeatmap: Codable {
|
||||||
|
let dates: [String: Int]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActivityTrend: Codable, Identifiable {
|
||||||
|
var id: String { date }
|
||||||
|
let date: String
|
||||||
|
let value: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActivityStreak: Codable {
|
||||||
|
let currentStreak: Int?
|
||||||
|
let longestStreak: Int?
|
||||||
|
let lastActiveDate: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActivityRecommendation: Codable, Identifiable {
|
||||||
|
var id: String { title ?? UUID().uuidString }
|
||||||
|
let type: String?
|
||||||
|
let title: String?
|
||||||
|
let description: String?
|
||||||
|
let priority: String?
|
||||||
|
}
|
||||||
30
AIStudyApp/AIStudyApp/Core/Models/Auth.swift
Normal file
30
AIStudyApp/AIStudyApp/Core/Models/Auth.swift
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AuthResponse: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
let user: AuthUser?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthUser: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let email: String?
|
||||||
|
let nickname: String?
|
||||||
|
let avatar: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppleAuthRequest: Codable {
|
||||||
|
let identityToken: String
|
||||||
|
let authorizationCode: String?
|
||||||
|
let fullName: AppleFullName?
|
||||||
|
let nonce: String?
|
||||||
|
|
||||||
|
struct AppleFullName: Codable {
|
||||||
|
let givenName: String?
|
||||||
|
let familyName: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RefreshRequest: Codable {
|
||||||
|
let refreshToken: String
|
||||||
|
}
|
||||||
72
AIStudyApp/AIStudyApp/Core/Models/Chat.swift
Normal file
72
AIStudyApp/AIStudyApp/Core/Models/Chat.swift
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Chat Session
|
||||||
|
|
||||||
|
struct ChatSession: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String?
|
||||||
|
let scopeType: String?
|
||||||
|
let scopeId: String?
|
||||||
|
let parentKnowledgeBaseId: String?
|
||||||
|
let messageCount: Int?
|
||||||
|
let isPinned: Bool?
|
||||||
|
let createdAt: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateSessionRequest: Codable {
|
||||||
|
let scopeType: String
|
||||||
|
let scopeId: String?
|
||||||
|
let parentKnowledgeBaseId: String?
|
||||||
|
let createdFrom: String?
|
||||||
|
let title: String?
|
||||||
|
let forceCreate: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat Message
|
||||||
|
|
||||||
|
struct ChatMessage: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let sessionId: String?
|
||||||
|
let role: String?
|
||||||
|
let content: String
|
||||||
|
let toolCalls: String?
|
||||||
|
let citations: [ChatCitation]?
|
||||||
|
let scopeSnapshot: ChatScopeSnapshot?
|
||||||
|
let createdAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatCitation: Codable {
|
||||||
|
let id: String
|
||||||
|
let title: String?
|
||||||
|
let content: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SaveChatMessageAsKnowledgeResponse: Codable {
|
||||||
|
let success: Bool
|
||||||
|
let message: String
|
||||||
|
let taskId: String?
|
||||||
|
let sourceId: String?
|
||||||
|
let chatMessageId: String?
|
||||||
|
let aiJobId: String?
|
||||||
|
let status: String?
|
||||||
|
let knowledgeItemCount: Int?
|
||||||
|
let errorCode: String?
|
||||||
|
let errorMessage: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatEntryContext: Codable, Hashable {
|
||||||
|
let scopeType: ChatScopeType
|
||||||
|
let scopeId: String?
|
||||||
|
let scopeName: String?
|
||||||
|
let parentKnowledgeBaseId: String?
|
||||||
|
let createdFrom: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatScopeType: String, Codable {
|
||||||
|
case knowledgeBase = "knowledge_base"
|
||||||
|
case knowledgeItem = "knowledge_item"
|
||||||
|
case material
|
||||||
|
case folder
|
||||||
|
case global
|
||||||
|
}
|
||||||
39
AIStudyApp/AIStudyApp/Core/Models/Common.swift
Normal file
39
AIStudyApp/AIStudyApp/Core/Models/Common.swift
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - API Envelope
|
||||||
|
|
||||||
|
struct APIEnvelope<T: Decodable>: Decodable {
|
||||||
|
let success: Bool
|
||||||
|
let data: T
|
||||||
|
let timestamp: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pagination
|
||||||
|
|
||||||
|
struct PaginationMeta: Codable {
|
||||||
|
let page: Int
|
||||||
|
let limit: Int
|
||||||
|
let total: Int
|
||||||
|
let totalPages: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PaginatedResponse<T: Decodable>: Decodable {
|
||||||
|
let items: [T]
|
||||||
|
let total: Int
|
||||||
|
let page: Int
|
||||||
|
let limit: Int
|
||||||
|
let totalPages: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generic
|
||||||
|
|
||||||
|
struct GenericSuccessResponse: Codable {
|
||||||
|
let success: Bool
|
||||||
|
let message: String?
|
||||||
|
let processed: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BatchDeleteResponse: Codable {
|
||||||
|
let deleted: Int
|
||||||
|
let message: String?
|
||||||
|
}
|
||||||
16
AIStudyApp/AIStudyApp/Core/Models/FocusItem.swift
Normal file
16
AIStudyApp/AIStudyApp/Core/Models/FocusItem.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FocusItem: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String?
|
||||||
|
let knowledgeBaseId: String?
|
||||||
|
let title: String
|
||||||
|
let content: String?
|
||||||
|
let masteryScore: Int?
|
||||||
|
let priority: String?
|
||||||
|
let status: String?
|
||||||
|
let sourceType: String?
|
||||||
|
let sourceId: String?
|
||||||
|
let createdAt: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
}
|
||||||
31
AIStudyApp/AIStudyApp/Core/Models/KnowledgeBase.swift
Normal file
31
AIStudyApp/AIStudyApp/Core/Models/KnowledgeBase.swift
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct KnowledgeBase: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String?
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let coverKey: String?
|
||||||
|
let coverUrl: String?
|
||||||
|
let coverType: String?
|
||||||
|
let visibility: String?
|
||||||
|
let isPinned: Bool?
|
||||||
|
let ownerType: String?
|
||||||
|
let isVerified: Bool?
|
||||||
|
let status: String?
|
||||||
|
let itemCount: Int?
|
||||||
|
let lastStudiedAt: String?
|
||||||
|
let createdAt: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateKnowledgeBaseRequest: Codable {
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let coverKey: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KnowledgeBaseCoverUploadResult {
|
||||||
|
let objectKey: String
|
||||||
|
let url: String?
|
||||||
|
}
|
||||||
69
AIStudyApp/AIStudyApp/Core/Models/KnowledgeItem.swift
Normal file
69
AIStudyApp/AIStudyApp/Core/Models/KnowledgeItem.swift
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct GenerateKnowledgeResponse: Codable {
|
||||||
|
let taskId: String
|
||||||
|
let sourceId: String?
|
||||||
|
let chatMessageId: String?
|
||||||
|
let aiJobId: String?
|
||||||
|
let status: String
|
||||||
|
let knowledgeItemCount: Int?
|
||||||
|
let errorCode: String?
|
||||||
|
let errorMessage: String?
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GenerateKnowledgeBatchResponse: Codable {
|
||||||
|
let knowledgeBaseId: String
|
||||||
|
let totalCount: Int
|
||||||
|
let queuedCount: Int
|
||||||
|
let waitingForParseCount: Int
|
||||||
|
let runningCount: Int
|
||||||
|
let tasks: [GenerateKnowledgeResponse]
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GenerateKnowledgeResponse {
|
||||||
|
var asTaskStatus: KnowledgeGenerationTaskStatus {
|
||||||
|
KnowledgeGenerationTaskStatus(
|
||||||
|
taskId: taskId,
|
||||||
|
sourceId: sourceId,
|
||||||
|
chatMessageId: chatMessageId,
|
||||||
|
aiJobId: aiJobId,
|
||||||
|
status: status,
|
||||||
|
knowledgeItemCount: knowledgeItemCount,
|
||||||
|
errorCode: errorCode,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KnowledgeItem: Codable, Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let userId: String?
|
||||||
|
let knowledgeBaseId: String?
|
||||||
|
let parentId: String?
|
||||||
|
let itemType: String?
|
||||||
|
let title: String
|
||||||
|
let content: String?
|
||||||
|
let summary: String?
|
||||||
|
let sourceType: String?
|
||||||
|
let sourceRef: String?
|
||||||
|
let orderIndex: Int?
|
||||||
|
let status: String?
|
||||||
|
let createdAt: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateKnowledgeItemRequest: Codable {
|
||||||
|
let knowledgeBaseId: String
|
||||||
|
let title: String
|
||||||
|
let content: String?
|
||||||
|
let itemType: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateKnowledgeItemRequest: Codable {
|
||||||
|
let title: String?
|
||||||
|
let content: String?
|
||||||
|
let summary: String?
|
||||||
|
}
|
||||||
47
AIStudyApp/AIStudyApp/Core/Models/KnowledgeSource.swift
Normal file
47
AIStudyApp/AIStudyApp/Core/Models/KnowledgeSource.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct KnowledgeSource: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let knowledgeBaseId: String?
|
||||||
|
let fileId: String?
|
||||||
|
let title: String?
|
||||||
|
let originalFilename: String?
|
||||||
|
let type: String?
|
||||||
|
let mimeType: String?
|
||||||
|
let parseStatus: String?
|
||||||
|
let indexStatus: String?
|
||||||
|
let learningStatus: String?
|
||||||
|
let textLength: Int?
|
||||||
|
let sizeBytes: Int?
|
||||||
|
let createdAt: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KnowledgeGenerationTaskStatus: Codable, Identifiable {
|
||||||
|
let taskId: String
|
||||||
|
let sourceId: String?
|
||||||
|
let chatMessageId: String?
|
||||||
|
let aiJobId: String?
|
||||||
|
let status: String
|
||||||
|
let knowledgeItemCount: Int?
|
||||||
|
let errorCode: String?
|
||||||
|
let errorMessage: String?
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var id: String { taskId }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KnowledgeGenerationTaskListResponse: Codable {
|
||||||
|
let knowledgeBaseId: String
|
||||||
|
let items: [KnowledgeGenerationTaskStatus]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AddSourceRequest: Codable {
|
||||||
|
let fileId: String?
|
||||||
|
let type: String?
|
||||||
|
let title: String?
|
||||||
|
let originalFilename: String?
|
||||||
|
let mimeType: String?
|
||||||
|
let sizeBytes: Int?
|
||||||
|
let originalObjectKey: String?
|
||||||
|
}
|
||||||
57
AIStudyApp/AIStudyApp/Core/Models/Quiz.swift
Normal file
57
AIStudyApp/AIStudyApp/Core/Models/Quiz.swift
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Quiz: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userId: String?
|
||||||
|
let knowledgeBaseId: String?
|
||||||
|
let title: String?
|
||||||
|
let description: String?
|
||||||
|
let questionCount: Int?
|
||||||
|
let sourceType: String?
|
||||||
|
let sourceId: String?
|
||||||
|
let status: String?
|
||||||
|
let createdAt: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuizQuestion: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let quizId: String?
|
||||||
|
let stem: String?
|
||||||
|
let questionText: String?
|
||||||
|
let questionType: String?
|
||||||
|
|
||||||
|
var questionTypeEnum: QuizQuestionType? {
|
||||||
|
guard let t = questionType else { return nil }
|
||||||
|
return QuizQuestionType(rawValue: t)
|
||||||
|
}
|
||||||
|
let options: [String]?
|
||||||
|
let answer: String?
|
||||||
|
let correctAnswer: String?
|
||||||
|
let explanation: String?
|
||||||
|
let orderIndex: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuizAttempt: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let quizId: String?
|
||||||
|
let userId: String?
|
||||||
|
let score: Int?
|
||||||
|
let correctCount: Int?
|
||||||
|
let totalQuestions: Int?
|
||||||
|
let answers: [QuizAttemptAnswer]?
|
||||||
|
let completedAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuizAttemptAnswer: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let questionId: String?
|
||||||
|
let question: QuizQuestion?
|
||||||
|
let userAnswer: String?
|
||||||
|
let isCorrect: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubmitQuizAnswerRequest: Codable {
|
||||||
|
let questionId: String
|
||||||
|
let answer: String
|
||||||
|
}
|
||||||
60
AIStudyApp/AIStudyApp/Core/Models/Reading.swift
Normal file
60
AIStudyApp/AIStudyApp/Core/Models/Reading.swift
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Reading Position (from old ReadingAPI)
|
||||||
|
|
||||||
|
struct ReadingPositionValue: Codable, Equatable, Hashable {
|
||||||
|
let type: String?
|
||||||
|
let pageNumber: UInt32?
|
||||||
|
let blockId: String?
|
||||||
|
let blockProgress: Float?
|
||||||
|
let scrollProgress: Float?
|
||||||
|
let lineNumber: UInt32?
|
||||||
|
let pageProgress: Float?
|
||||||
|
let overallProgress: Float?
|
||||||
|
let zoomScale: Float?
|
||||||
|
let offsetX: Float?
|
||||||
|
let offsetY: Float?
|
||||||
|
let chapterId: String?
|
||||||
|
let chapterProgress: Float?
|
||||||
|
let offset: Int?
|
||||||
|
let percent: Float?
|
||||||
|
let section: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Continue Learning
|
||||||
|
|
||||||
|
struct ContinueLearningResponse: Codable {
|
||||||
|
let type: String?
|
||||||
|
let materialId: String?
|
||||||
|
let title: String?
|
||||||
|
let lastPosition: ReadingPositionValue?
|
||||||
|
let lastProgress: Float?
|
||||||
|
let totalActiveSeconds: Int?
|
||||||
|
let lastReadAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LearningSummaryResponse: Codable {
|
||||||
|
let todaySeconds: Int
|
||||||
|
let weekSeconds: Int
|
||||||
|
let markedReadCount: Int
|
||||||
|
let materialsReadCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LearningTrendResponse: Codable {
|
||||||
|
let days: Int
|
||||||
|
let series: [TrendPoint]
|
||||||
|
struct TrendPoint: Codable {
|
||||||
|
let date: String
|
||||||
|
let value: Int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LearningRecordsResponse: Codable {
|
||||||
|
let items: [RecordItem]
|
||||||
|
struct RecordItem: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let recordType: String
|
||||||
|
let title: String
|
||||||
|
let durationSeconds: Int
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,14 +74,56 @@ private struct PositionWrapper: Encodable {
|
|||||||
|
|
||||||
init(from pos: ReadingPosition) {
|
init(from pos: ReadingPosition) {
|
||||||
switch pos {
|
switch pos {
|
||||||
|
case .block(let blockId, let blockProgress, let overallProgress):
|
||||||
|
raw = [
|
||||||
|
"type": .string("Block"),
|
||||||
|
"blockId": .string(blockId),
|
||||||
|
"blockProgress": .float(blockProgress),
|
||||||
|
"overallProgress": .float(overallProgress)
|
||||||
|
]
|
||||||
case .markdown(let blockId, let scrollProgress):
|
case .markdown(let blockId, let scrollProgress):
|
||||||
raw = ["type": .string("Markdown"), "blockId": .string(blockId), "scrollProgress": .float(scrollProgress)]
|
raw = ["type": .string("Markdown"), "blockId": .string(blockId), "scrollProgress": .float(scrollProgress)]
|
||||||
case .text(let lineNumber, let scrollProgress):
|
case .text(let lineNumber, let scrollProgress):
|
||||||
raw = ["type": .string("Text"), "lineNumber": .int(Int(lineNumber)), "scrollProgress": .float(scrollProgress)]
|
raw = ["type": .string("Text"), "lineNumber": .int(Int(lineNumber)), "scrollProgress": .float(scrollProgress)]
|
||||||
case .pdf(let pageNumber, let pageProgress, let overallProgress):
|
case .pdf(let pageNumber, let pageProgress, let overallProgress):
|
||||||
raw = ["type": .string("Pdf"), "pageNumber": .int(Int(pageNumber)), "pageProgress": .float(pageProgress), "overallProgress": .float(overallProgress)]
|
raw = ["type": .string("Pdf"), "pageNumber": .int(Int(pageNumber)), "pageProgress": .float(pageProgress), "overallProgress": .float(overallProgress)]
|
||||||
|
case .pdfViewport(let pageNumber, let anchorX, let anchorY, let zoomScale, let overallProgress):
|
||||||
|
raw = [
|
||||||
|
"type": .string("PdfViewport"),
|
||||||
|
"pageNumber": .int(Int(pageNumber)),
|
||||||
|
"anchorX": .float(anchorX),
|
||||||
|
"anchorY": .float(anchorY),
|
||||||
|
"zoomScale": .float(zoomScale),
|
||||||
|
"overallProgress": .float(overallProgress)
|
||||||
|
]
|
||||||
case .image(let zoomScale, let offsetX, let offsetY):
|
case .image(let zoomScale, let offsetX, let offsetY):
|
||||||
raw = ["type": .string("Image"), "zoomScale": .float(zoomScale), "offsetX": .float(offsetX), "offsetY": .float(offsetY)]
|
raw = ["type": .string("Image"), "zoomScale": .float(zoomScale), "offsetX": .float(offsetX), "offsetY": .float(offsetY)]
|
||||||
|
case .imageViewport(let zoomScale, let anchorX, let anchorY):
|
||||||
|
raw = ["type": .string("ImageViewport"), "zoomScale": .float(zoomScale), "anchorX": .float(anchorX), "anchorY": .float(anchorY)]
|
||||||
|
case .workbook(let sheetId, let topRow, let leftColumn, let selectedCell, let zoomScale, let overallProgress):
|
||||||
|
var value: [String: AnyCodableValue] = [
|
||||||
|
"type": .string("Workbook"),
|
||||||
|
"sheetId": .string(sheetId),
|
||||||
|
"topRow": .int(Int(topRow)),
|
||||||
|
"leftColumn": .int(Int(leftColumn)),
|
||||||
|
"zoomScale": .float(zoomScale)
|
||||||
|
]
|
||||||
|
if let selectedCell {
|
||||||
|
value["selectedCell"] = .string(selectedCell)
|
||||||
|
}
|
||||||
|
if let overallProgress {
|
||||||
|
value["overallProgress"] = .float(overallProgress)
|
||||||
|
}
|
||||||
|
raw = value
|
||||||
|
case .slide(let slideIndex, let zoomScale, let anchorX, let anchorY, let overallProgress):
|
||||||
|
raw = [
|
||||||
|
"type": .string("Slide"),
|
||||||
|
"slideIndex": .int(Int(slideIndex)),
|
||||||
|
"zoomScale": .float(zoomScale),
|
||||||
|
"anchorX": .float(anchorX),
|
||||||
|
"anchorY": .float(anchorY),
|
||||||
|
"overallProgress": .float(overallProgress)
|
||||||
|
]
|
||||||
case .epub(let chapterId, let chapterProgress, let overallProgress):
|
case .epub(let chapterId, let chapterProgress, let overallProgress):
|
||||||
raw = ["type": .string("Epub"), "chapterId": .string(chapterId), "chapterProgress": .float(chapterProgress), "overallProgress": .float(overallProgress)]
|
raw = ["type": .string("Epub"), "chapterId": .string(chapterId), "chapterProgress": .float(chapterProgress), "overallProgress": .float(overallProgress)]
|
||||||
case .unknown:
|
case .unknown:
|
||||||
|
|||||||
@ -72,8 +72,8 @@ extension PreviewMode: Codable {
|
|||||||
// MARK: - ReadingMaterialContext
|
// MARK: - ReadingMaterialContext
|
||||||
|
|
||||||
/// iOS-side context that supplements Rust's materialId with business fields.
|
/// iOS-side context that supplements Rust's materialId with business fields.
|
||||||
/// Rust only knows materialId; iOS adds readingTargetType, knowledgeBaseId,
|
/// Carries file metadata needed to build `ReadingMaterialRefV2` for
|
||||||
/// material type metadata, and local file info for API upload.
|
/// the Rust V2 reading session FFI.
|
||||||
struct ReadingMaterialContext: Codable, Equatable {
|
struct ReadingMaterialContext: Codable, Equatable {
|
||||||
let readingTargetType: ReadingTargetType
|
let readingTargetType: ReadingTargetType
|
||||||
let materialId: String
|
let materialId: String
|
||||||
@ -83,6 +83,13 @@ struct ReadingMaterialContext: Codable, Equatable {
|
|||||||
let materialType: MaterialType?
|
let materialType: MaterialType?
|
||||||
let previewMode: PreviewMode?
|
let previewMode: PreviewMode?
|
||||||
|
|
||||||
|
// V2 material metadata (for ReadingMaterialRefV2)
|
||||||
|
let filePath: String
|
||||||
|
let fileName: String
|
||||||
|
let fileType: String // lowercase: "markdown", "pdf", "epub", etc.
|
||||||
|
let mimeType: String?
|
||||||
|
let fileSize: UInt64?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
readingTargetType: ReadingTargetType,
|
readingTargetType: ReadingTargetType,
|
||||||
materialId: String,
|
materialId: String,
|
||||||
@ -90,7 +97,12 @@ struct ReadingMaterialContext: Codable, Equatable {
|
|||||||
title: String? = nil,
|
title: String? = nil,
|
||||||
localFileURL: URL? = nil,
|
localFileURL: URL? = nil,
|
||||||
materialType: MaterialType? = nil,
|
materialType: MaterialType? = nil,
|
||||||
previewMode: PreviewMode? = nil
|
previewMode: PreviewMode? = nil,
|
||||||
|
filePath: String = "",
|
||||||
|
fileName: String = "",
|
||||||
|
fileType: String = "unknown",
|
||||||
|
mimeType: String? = nil,
|
||||||
|
fileSize: UInt64? = nil
|
||||||
) {
|
) {
|
||||||
self.readingTargetType = readingTargetType
|
self.readingTargetType = readingTargetType
|
||||||
self.materialId = materialId
|
self.materialId = materialId
|
||||||
@ -99,5 +111,29 @@ struct ReadingMaterialContext: Codable, Equatable {
|
|||||||
self.localFileURL = localFileURL
|
self.localFileURL = localFileURL
|
||||||
self.materialType = materialType
|
self.materialType = materialType
|
||||||
self.previewMode = previewMode
|
self.previewMode = previewMode
|
||||||
|
self.filePath = filePath
|
||||||
|
self.fileName = fileName
|
||||||
|
self.fileType = fileType
|
||||||
|
self.mimeType = mimeType
|
||||||
|
self.fileSize = fileSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MaterialType → file_type string
|
||||||
|
|
||||||
|
extension MaterialType {
|
||||||
|
/// Lowercase string matching `ReadingMaterialRefV2.fileType` Rust convention.
|
||||||
|
var fileTypeString: String {
|
||||||
|
switch self {
|
||||||
|
case .markdown: return "markdown"
|
||||||
|
case .text: return "text"
|
||||||
|
case .pdf: return "pdf"
|
||||||
|
case .image: return "image"
|
||||||
|
case .epub: return "epub"
|
||||||
|
case .word: return "word"
|
||||||
|
case .excel: return "excel"
|
||||||
|
case .powerPoint: return "powerpoint"
|
||||||
|
case .unknown: return "unknown"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
AIStudyApp/AIStudyApp/Core/Models/User.swift
Normal file
69
AIStudyApp/AIStudyApp/Core/Models/User.swift
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - User Profile
|
||||||
|
|
||||||
|
struct UserProfileResponse: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let email: String?
|
||||||
|
let nickname: String?
|
||||||
|
let avatarUrl: String?
|
||||||
|
let role: String?
|
||||||
|
let status: String?
|
||||||
|
let onboardingCompleted: Bool?
|
||||||
|
let createdAt: String?
|
||||||
|
let profile: UserProfileData?
|
||||||
|
let preferences: UserPreferences?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserProfileData: Codable {
|
||||||
|
let learningIdentity: String?
|
||||||
|
let learningDirection: String?
|
||||||
|
let bio: String?
|
||||||
|
let currentGoal: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserPreferences: Codable, Equatable {
|
||||||
|
let preferredMethods: [String]?
|
||||||
|
let defaultFocusMinutes: Int?
|
||||||
|
let aiSuggestionLevel: String?
|
||||||
|
let language: String?
|
||||||
|
let appearance: String?
|
||||||
|
let notificationEnabled: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateProfileRequest: Codable {
|
||||||
|
let nickname: String?
|
||||||
|
let avatarUrl: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdatePreferencesRequest: Codable {
|
||||||
|
let preferredMethods: [String]?
|
||||||
|
let defaultFocusMinutes: Int?
|
||||||
|
let aiSuggestionLevel: String?
|
||||||
|
let language: String?
|
||||||
|
let appearance: String?
|
||||||
|
let notificationEnabled: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateProfileDataRequest: Codable {
|
||||||
|
let learningIdentity: String?
|
||||||
|
let learningDirection: String?
|
||||||
|
let bio: String?
|
||||||
|
let currentGoal: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device
|
||||||
|
|
||||||
|
struct UserDevice: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let deviceName: String?
|
||||||
|
let platform: String?
|
||||||
|
let lastActiveAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Preferences
|
||||||
|
|
||||||
|
struct NotificationPreferences: Codable {
|
||||||
|
let pushEnabled: Bool?
|
||||||
|
let emailEnabled: Bool?
|
||||||
|
}
|
||||||
@ -24,17 +24,14 @@ enum Route: Hashable {
|
|||||||
case learningSession(taskTitle: String, taskType: String, taskColorHex: String)
|
case learningSession(taskTitle: String, taskType: String, taskColorHex: String)
|
||||||
case studyHome
|
case studyHome
|
||||||
|
|
||||||
// Import
|
|
||||||
case importReview(sourceId: String)
|
|
||||||
|
|
||||||
// Quiz
|
// Quiz
|
||||||
case quizList(knowledgeBaseId: String)
|
case quizList(knowledgeBaseId: String)
|
||||||
case quizTake(quizId: String)
|
case quizTake(quizId: String)
|
||||||
case quizResult(quizId: String, attemptId: String)
|
case quizResult(quizId: String, attemptId: String)
|
||||||
|
|
||||||
// Material Reader
|
// Material Reader
|
||||||
case materialReader(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "")
|
case materialReader(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", resumePosition: ReadingPositionValue? = nil)
|
||||||
case materialDetail(knowledgeBaseId: String, fileName: String, fileType: MaterialType, fileSize: UInt64, filePath: String, uploadDate: String?)
|
case materialDetail(knowledgeBaseId: String, sourceId: String, fileName: String, fileType: MaterialType, fileSize: UInt64, filePath: String, uploadDate: String?)
|
||||||
|
|
||||||
// Membership
|
// Membership
|
||||||
case membership
|
case membership
|
||||||
@ -75,7 +72,6 @@ extension Route {
|
|||||||
case .feedbackForm: "FeedbackFormView"
|
case .feedbackForm: "FeedbackFormView"
|
||||||
case .membership: "MembershipView"
|
case .membership: "MembershipView"
|
||||||
case .editProfile: "EditProfilePage"
|
case .editProfile: "EditProfilePage"
|
||||||
case .importReview: "ImportReviewPage"
|
|
||||||
case .quizList: "QuizListView"
|
case .quizList: "QuizListView"
|
||||||
case .quizTake: "QuizTakerView"
|
case .quizTake: "QuizTakerView"
|
||||||
case .quizResult: "QuizResultView"
|
case .quizResult: "QuizResultView"
|
||||||
@ -116,13 +112,12 @@ extension Route {
|
|||||||
case .methodPreference: AnyView(MethodPreferenceView())
|
case .methodPreference: AnyView(MethodPreferenceView())
|
||||||
case .feedbackForm: AnyView(FeedbackFormView())
|
case .feedbackForm: AnyView(FeedbackFormView())
|
||||||
case .editProfile: AnyView(EditProfilePage())
|
case .editProfile: AnyView(EditProfilePage())
|
||||||
case .importReview(let sourceId): AnyView(ImportReviewPage(sourceId: sourceId))
|
|
||||||
case .quizList(let kbId): AnyView(QuizListView(knowledgeBaseId: kbId))
|
case .quizList(let kbId): AnyView(QuizListView(knowledgeBaseId: kbId))
|
||||||
case .quizTake(let id): AnyView(QuizTakerView(quizId: id))
|
case .quizTake(let id): AnyView(QuizTakerView(quizId: id))
|
||||||
case .quizResult(let qid, let aid): AnyView(QuizResultView(quizId: qid, attemptId: aid))
|
case .quizResult(let qid, let aid): AnyView(QuizResultView(quizId: qid, attemptId: aid))
|
||||||
case .materialReader(let mid, let path, let mt, let kbId, let title): AnyView(MaterialReaderView(materialId: mid, filePath: path, materialType: mt, knowledgeBaseId: kbId, title: title))
|
case .materialReader(let mid, let path, let mt, let kbId, let title, let rp): AnyView(MaterialReaderView(materialId: mid, filePath: path, materialType: mt, knowledgeBaseId: kbId, title: title, resumePosition: rp))
|
||||||
case .materialDetail(let kbId, let name, let type, let size, let path, let date):
|
case .materialDetail(let kbId, let sourceId, let name, let type, let size, let path, let date):
|
||||||
AnyView(MaterialDetailView(knowledgeBaseId: kbId, fileName: name, fileType: type, fileSize: size, filePath: path, uploadDate: date))
|
AnyView(MaterialDetailView(knowledgeBaseId: kbId, sourceId: sourceId, fileName: name, fileType: type, fileSize: size, filePath: path, uploadDate: date))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@ -166,12 +166,19 @@ extension APIClient {
|
|||||||
|
|
||||||
private func decodeResponse<T: Decodable>(_ data: Data) throws -> T {
|
private func decodeResponse<T: Decodable>(_ data: Data) throws -> T {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
// Try unwrapped response first (no envelope), then wrapped
|
// Check for error envelope first: { success: false, statusCode, message }
|
||||||
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let success = json["success"] as? Bool, !success {
|
||||||
|
let message = json["message"] as? String ?? "请求失败"
|
||||||
|
let code = json["errorCode"] as? String
|
||||||
|
throw APIError.serverError(message, code: code)
|
||||||
|
}
|
||||||
|
// Try unwrapped response (no envelope)
|
||||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
json["data"] == nil && json["success"] == nil {
|
json["data"] == nil && json["success"] == nil {
|
||||||
return try decoder.decode(T.self, from: data)
|
return try decoder.decode(T.self, from: data)
|
||||||
}
|
}
|
||||||
// Has envelope wrapper
|
// Has envelope wrapper: { success: true, data: ..., timestamp }
|
||||||
do {
|
do {
|
||||||
let envelope = try decoder.decode(APIEnvelope<T>.self, from: data)
|
let envelope = try decoder.decode(APIEnvelope<T>.self, from: data)
|
||||||
return envelope.data
|
return envelope.data
|
||||||
|
|||||||
12
AIStudyApp/AIStudyApp/Core/Services/API/AIAnalysisAPI.swift
Normal file
12
AIStudyApp/AIStudyApp/Core/Services/API/AIAnalysisAPI.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AIAnalysisService {
|
||||||
|
static let shared = AIAnalysisService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func analyze(questionText: String, knowledgeItemContent: String, userAnswer: String) async throws -> AIAnalysisResult {
|
||||||
|
let body = AIAnalysisRequest(questionText: questionText, knowledgeItemContent: knowledgeItemContent, userAnswer: userAnswer, text: nil, type: nil)
|
||||||
|
return try await client.request("/ai-analysis", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ActiveRecallService {
|
||||||
|
static let shared = ActiveRecallService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func questions(page: Int = 1, limit: Int = 20) async throws -> [ActiveRecallQuestion] {
|
||||||
|
try await client.request("/active-recalls", queryItems: [
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit(questionId: String, answerText: String) async throws -> ActiveRecallAnswer {
|
||||||
|
let body = SubmitAnswerRequest(answerText: answerText)
|
||||||
|
return try await client.request("/active-recalls/\(questionId)/submit", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
AIStudyApp/AIStudyApp/Core/Services/API/ActivityAPI.swift
Normal file
28
AIStudyApp/AIStudyApp/Core/Services/API/ActivityAPI.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ActivityService {
|
||||||
|
static let shared = ActivityService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func summary() async throws -> ActivitySummary {
|
||||||
|
try await client.request("/activity/summary")
|
||||||
|
}
|
||||||
|
|
||||||
|
func heatmap() async throws -> [String: Int] {
|
||||||
|
try await client.request("/activity/heatmap")
|
||||||
|
}
|
||||||
|
|
||||||
|
func trend(days: Int = 7) async throws -> [ActivityTrend] {
|
||||||
|
try await client.request("/activity/trend?days=\(days)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func streak() async throws -> ActivityStreak {
|
||||||
|
let offset = TimeZone.current.secondsFromGMT() / 60
|
||||||
|
return try await client.request("/activity/streak?tzOffset=\(offset)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func recommendations() async throws -> [ActivityRecommendation] {
|
||||||
|
try await client.request("/activity/recommendations")
|
||||||
|
}
|
||||||
|
}
|
||||||
25
AIStudyApp/AIStudyApp/Core/Services/API/AuthAPI.swift
Normal file
25
AIStudyApp/AIStudyApp/Core/Services/API/AuthAPI.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AuthService {
|
||||||
|
static let shared = AuthService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func appleLogin(
|
||||||
|
identityToken: String,
|
||||||
|
authorizationCode: String? = nil,
|
||||||
|
givenName: String?,
|
||||||
|
familyName: String?,
|
||||||
|
nonce: String? = nil
|
||||||
|
) async throws -> AuthResponse {
|
||||||
|
let body = AppleAuthRequest(
|
||||||
|
identityToken: identityToken,
|
||||||
|
authorizationCode: authorizationCode,
|
||||||
|
fullName: givenName != nil
|
||||||
|
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
|
||||||
|
: nil,
|
||||||
|
nonce: nonce
|
||||||
|
)
|
||||||
|
return try await client.request("/auth/apple", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
AIStudyApp/AIStudyApp/Core/Services/API/ChatAPI.swift
Normal file
102
AIStudyApp/AIStudyApp/Core/Services/API/ChatAPI.swift
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - SSE Stream Types
|
||||||
|
|
||||||
|
struct SSEChunk: Codable {
|
||||||
|
let type: String?
|
||||||
|
let delta: String?
|
||||||
|
let content: String?
|
||||||
|
let error: String?
|
||||||
|
let conversationId: String?
|
||||||
|
let toolCalls: String?
|
||||||
|
let citations: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Service
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RagChatService {
|
||||||
|
static let shared = RagChatService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func createSession(ctx: ChatEntryContext, forceCreate: Bool = false) async throws -> ChatSession {
|
||||||
|
let body = CreateSessionRequest(
|
||||||
|
scopeType: ctx.scopeType.rawValue, scopeId: ctx.scopeId,
|
||||||
|
parentKnowledgeBaseId: ctx.parentKnowledgeBaseId,
|
||||||
|
createdFrom: ctx.createdFrom, title: nil, forceCreate: forceCreate ? true : nil
|
||||||
|
)
|
||||||
|
return try await client.request("/rag-chat/sessions", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSessions(scopeType: String? = nil, scopeId: String? = nil, parentKnowledgeBaseId: String? = nil) async throws -> [ChatSession] {
|
||||||
|
var items: [URLQueryItem] = []
|
||||||
|
if let st = scopeType { items.append(URLQueryItem(name: "scopeType", value: st)) }
|
||||||
|
if let si = scopeId { items.append(URLQueryItem(name: "scopeId", value: si)) }
|
||||||
|
if let pk = parentKnowledgeBaseId { items.append(URLQueryItem(name: "parentKnowledgeBaseId", value: pk)) }
|
||||||
|
return try await client.request("/rag-chat/sessions", queryItems: items.isEmpty ? nil : items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func messages(sessionId: String) async throws -> [ChatMessage] {
|
||||||
|
try await client.request("/rag-chat/sessions/\(sessionId)/messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMessages(sessionId: String) async throws -> [ChatMessage] {
|
||||||
|
try await messages(sessionId: sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSession(id: String, dto: UpdateChatSessionRequest) async throws -> ChatSession {
|
||||||
|
try await client.request("/rag-chat/sessions/\(id)", method: "PATCH", body: dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSession(id: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/rag-chat/sessions/\(id)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveMessageAsKnowledge(
|
||||||
|
sessionId: String,
|
||||||
|
messageId: String,
|
||||||
|
knowledgeBaseId: String? = nil,
|
||||||
|
title: String? = nil,
|
||||||
|
summary: String? = nil
|
||||||
|
) async throws -> SaveChatMessageAsKnowledgeResponse {
|
||||||
|
let body: [String: String?] = [
|
||||||
|
"knowledgeBaseId": knowledgeBaseId,
|
||||||
|
"title": title,
|
||||||
|
"summary": summary
|
||||||
|
]
|
||||||
|
return try await client.request(
|
||||||
|
"/rag-chat/sessions/\(sessionId)/messages/\(messageId)/save-as-knowledge",
|
||||||
|
method: "POST",
|
||||||
|
body: body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SSE Streaming
|
||||||
|
|
||||||
|
func sendMessageStream(sessionId: String, content: String) -> AsyncThrowingStream<SSEChunk, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
Task {
|
||||||
|
let token = await client.getToken() ?? ""
|
||||||
|
let url = URL(string: "\(APIConfig.baseURL)/rag-chat/sessions/\(sessionId)/stream")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = try? JSONEncoder().encode(SendMessageRequest(content: content))
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (bytes, _) = try await URLSession.shared.bytes(for: request)
|
||||||
|
for try await line in bytes.lines {
|
||||||
|
guard let data = line.data(using: .utf8),
|
||||||
|
let chunk = try? JSONDecoder().decode(SSEChunk.self, from: data) else { continue }
|
||||||
|
continuation.yield(chunk)
|
||||||
|
if chunk.type == "done" || chunk.type == "error" { break }
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
AIStudyApp/AIStudyApp/Core/Services/API/FeedbackAPI.swift
Normal file
12
AIStudyApp/AIStudyApp/Core/Services/API/FeedbackAPI.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FeedbackService {
|
||||||
|
static let shared = FeedbackService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func submit(category: String = "general", content: String, email: String? = nil) async throws -> FeedbackData {
|
||||||
|
let body = FeedbackCreateRequest(category: category, content: content, email: email)
|
||||||
|
return try await client.request("/feedback", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
AIStudyApp/AIStudyApp/Core/Services/API/FocusItemAPI.swift
Normal file
32
AIStudyApp/AIStudyApp/Core/Services/API/FocusItemAPI.swift
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FocusItemUpdateRequest: Codable {
|
||||||
|
let masteryScore: Int?
|
||||||
|
let title: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FocusItemCompleteRequest: Codable {
|
||||||
|
let completed: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FocusItemService {
|
||||||
|
static let shared = FocusItemService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func list(page: Int = 1, limit: Int = 20) async throws -> [FocusItem] {
|
||||||
|
try await client.request("/focus-items", queryItems: [
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(id: String, masteryScore: Int? = nil, title: String? = nil) async throws -> FocusItem {
|
||||||
|
let body = FocusItemUpdateRequest(masteryScore: masteryScore, title: title)
|
||||||
|
return try await client.request("/focus-items/\(id)", method: "PATCH", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func complete(id: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/focus-items/\(id)/complete", method: "POST")
|
||||||
|
}
|
||||||
|
}
|
||||||
38
AIStudyApp/AIStudyApp/Core/Services/API/ImportAPI.swift
Normal file
38
AIStudyApp/AIStudyApp/Core/Services/API/ImportAPI.swift
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Document Import
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DocumentImportService {
|
||||||
|
static let shared = DocumentImportService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func status(importId: String) async throws -> ImportStatusResponse {
|
||||||
|
try await client.request("/document-import/\(importId)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(knowledgeBaseId: String, fileName: String? = nil, sourceType: String? = nil, rawText: String? = nil) async throws -> ImportStatusResponse {
|
||||||
|
let body: [String: String] = [
|
||||||
|
"knowledgeBaseId": knowledgeBaseId,
|
||||||
|
"fileName": fileName ?? "",
|
||||||
|
"sourceType": sourceType ?? "",
|
||||||
|
"rawText": rawText ?? "",
|
||||||
|
]
|
||||||
|
return try await client.request("/document-import", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImportStatusResponse: Codable {
|
||||||
|
let id: String
|
||||||
|
let status: String?
|
||||||
|
let progress: Int?
|
||||||
|
let errorMessage: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateImportRequest: Codable {
|
||||||
|
let knowledgeBaseId: String
|
||||||
|
let fileId: String?
|
||||||
|
let sourceUrl: String?
|
||||||
|
let text: String?
|
||||||
|
let title: String?
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class KnowledgeBaseService {
|
||||||
|
static let shared = KnowledgeBaseService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func list(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
|
||||||
|
try await client.request("/knowledge-bases", queryItems: [
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(title: String, description: String?, coverKey: String? = nil) async throws -> KnowledgeBase {
|
||||||
|
let body = CreateKnowledgeBaseRequest(title: title, description: description, coverKey: coverKey)
|
||||||
|
return try await client.request("/knowledge-bases", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detail(id: String) async throws -> KnowledgeBase {
|
||||||
|
try await client.request("/knowledge-bases/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(id: String, title: String?, description: String?) async throws -> KnowledgeBase {
|
||||||
|
let body = CreateKnowledgeBaseRequest(title: title ?? "", description: description, coverKey: nil)
|
||||||
|
return try await client.request("/knowledge-bases/\(id)", method: "PATCH", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(id: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/knowledge-bases/\(id)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribe(kbId: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/knowledge-bases/\(kbId)/subscribe", method: "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsubscribe(kbId: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/knowledge-bases/\(kbId)/subscribe", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSubscribed(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
|
||||||
|
try await client.request("/knowledge-bases/subscribed", queryItems: [
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func discover(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
|
||||||
|
try await client.request("/knowledge-bases", queryItems: [
|
||||||
|
URLQueryItem(name: "visibility", value: "public"),
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class KnowledgeFolderService {
|
||||||
|
static let shared = KnowledgeFolderService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func list(knowledgeBaseId: String) async throws -> [KnowledgeFolder] {
|
||||||
|
try await client.request("/knowledge-bases/\(knowledgeBaseId)/folders")
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(kbId: String, name: String, parentId: String? = nil) async throws -> KnowledgeFolder {
|
||||||
|
let body = CreateFolderRequest(name: name, parentId: parentId)
|
||||||
|
return try await client.request("/knowledge-bases/\(kbId)/folders", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(kbId: String, folderId: String, name: String) async throws -> KnowledgeFolder {
|
||||||
|
let body = CreateFolderRequest(name: name, parentId: nil)
|
||||||
|
return try await client.request("/knowledge-bases/\(kbId)/folders/\(folderId)", method: "PATCH", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(kbId: String, folderId: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/knowledge-bases/\(kbId)/folders/\(folderId)", method: "DELETE")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class KnowledgeItemService {
|
||||||
|
static let shared = KnowledgeItemService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func list(knowledgeBaseId: String, sortBy: String? = nil, order: String? = nil) async throws -> [KnowledgeItem] {
|
||||||
|
var items = [URLQueryItem(name: "knowledgeBaseId", value: knowledgeBaseId)]
|
||||||
|
if let s = sortBy { items.append(URLQueryItem(name: "sortBy", value: s)) }
|
||||||
|
if let o = order { items.append(URLQueryItem(name: "order", value: o)) }
|
||||||
|
return try await client.request("/knowledge-items", queryItems: items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detail(id: String) async throws -> KnowledgeItem {
|
||||||
|
try await client.request("/knowledge-items/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(knowledgeBaseId: String, title: String, content: String?, itemType: String? = nil) async throws -> KnowledgeItem {
|
||||||
|
let body = CreateKnowledgeItemRequest(knowledgeBaseId: knowledgeBaseId, title: title, content: content, itemType: itemType)
|
||||||
|
return try await client.request("/knowledge-items", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(id: String, title: String?, content: String?, summary: String?) async throws -> KnowledgeItem {
|
||||||
|
let body = UpdateKnowledgeItemRequest(title: title, content: content, summary: summary)
|
||||||
|
return try await client.request("/knowledge-items/\(id)", method: "PATCH", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(id: String) async throws {
|
||||||
|
let _: GenericSuccessResponse = try await client.request("/knowledge-items/\(id)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchDelete(ids: [String]) async throws -> BatchDeleteResponse {
|
||||||
|
try await client.request("/knowledge-items/batch-delete", method: "POST", body: ["ids": ids])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class KnowledgeSourceService {
|
||||||
|
static let shared = KnowledgeSourceService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func list(kbId: String, sortBy: String? = nil, order: String? = nil) async throws -> [KnowledgeSource] {
|
||||||
|
var items = [URLQueryItem]()
|
||||||
|
if let s = sortBy { items.append(URLQueryItem(name: "sortBy", value: s)) }
|
||||||
|
if let o = order { items.append(URLQueryItem(name: "order", value: o)) }
|
||||||
|
return try await client.request("/knowledge-bases/\(kbId)/sources", queryItems: items.isEmpty ? nil : items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detail(kbId: String, id: String) async throws -> KnowledgeSource {
|
||||||
|
try await client.request("/knowledge-bases/\(kbId)/sources/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(
|
||||||
|
kbId: String,
|
||||||
|
fileId: String? = nil,
|
||||||
|
type: String? = nil,
|
||||||
|
title: String? = nil,
|
||||||
|
originalFilename: String? = nil,
|
||||||
|
mimeType: String? = nil,
|
||||||
|
sizeBytes: Int? = nil,
|
||||||
|
originalObjectKey: String? = nil
|
||||||
|
) async throws -> KnowledgeSource {
|
||||||
|
let body = AddSourceRequest(
|
||||||
|
fileId: fileId, type: type, title: title,
|
||||||
|
originalFilename: originalFilename, mimeType: mimeType,
|
||||||
|
sizeBytes: sizeBytes, originalObjectKey: originalObjectKey
|
||||||
|
)
|
||||||
|
return try await client.request("/knowledge-bases/\(kbId)/sources", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(kbId: String, id: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/knowledge-bases/\(kbId)/sources/\(id)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse(kbId: String, sourceId: String) async throws -> ParseResponse {
|
||||||
|
try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/parse", method: "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateKnowledge(kbId: String, sourceId: String) async throws -> GenerateKnowledgeResponse {
|
||||||
|
try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/generate-knowledge", method: "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateKnowledgeBatch(kbId: String, sourceIds: [String]) async throws -> GenerateKnowledgeBatchResponse {
|
||||||
|
try await client.request(
|
||||||
|
"/knowledge-bases/\(kbId)/sources/generate-knowledge-batch",
|
||||||
|
method: "POST",
|
||||||
|
body: ["sourceIds": sourceIds]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestKnowledgeGenerationTask(kbId: String, sourceId: String) async throws -> KnowledgeGenerationTaskStatus? {
|
||||||
|
try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/knowledge-generation-task")
|
||||||
|
}
|
||||||
|
|
||||||
|
func knowledgeGenerationTasks(kbId: String, sourceIds: [String] = []) async throws -> KnowledgeGenerationTaskListResponse {
|
||||||
|
let queryItems = sourceIds.isEmpty
|
||||||
|
? nil
|
||||||
|
: [URLQueryItem(name: "sourceIds", value: sourceIds.joined(separator: ","))]
|
||||||
|
return try await client.request("/knowledge-bases/\(kbId)/sources/knowledge-generation-tasks", queryItems: queryItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LearningSessionService {
|
||||||
|
static let shared = LearningSessionService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func list(page: Int = 1, limit: Int = 20) async throws -> [LearningSession] {
|
||||||
|
try await client.request("/learning-sessions", queryItems: [
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(knowledgeBaseId: String? = nil, knowledgeItemId: String? = nil, mode: String? = nil) async throws -> LearningSession {
|
||||||
|
let body = CreateLearningSessionRequest(knowledgeBaseId: knowledgeBaseId, knowledgeItemId: knowledgeItemId, mode: mode)
|
||||||
|
return try await client.request("/learning-sessions", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func end(id: String) async throws -> LearningSession {
|
||||||
|
try await client.request("/learning-sessions/\(id)/end", method: "POST")
|
||||||
|
}
|
||||||
|
}
|
||||||
27
AIStudyApp/AIStudyApp/Core/Services/API/MembershipAPI.swift
Normal file
27
AIStudyApp/AIStudyApp/Core/Services/API/MembershipAPI.swift
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MembershipService {
|
||||||
|
static let shared = MembershipService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func myMembership() async throws -> MyMembershipResponse {
|
||||||
|
try await client.request("/membership/my")
|
||||||
|
}
|
||||||
|
|
||||||
|
func plans() async throws -> MembershipPlanResponse {
|
||||||
|
try await client.request("/membership/plans")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPlans() async throws -> MembershipPlanResponse {
|
||||||
|
try await plans()
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitTransaction(_ dto: TransactionSubmitBody) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/membership/apple/transactions", method: "POST", body: dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func entitlements() async throws -> Entitlements {
|
||||||
|
try await client.request("/membership/entitlements")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NotificationService {
|
||||||
|
static let shared = NotificationService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func list(page: Int = 1, limit: Int = 20) async throws -> [NotificationItem] {
|
||||||
|
try await client.request("/notifications", queryItems: [
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func markRead(id: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/notifications/\(id)/read", method: "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markAllRead() async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/notifications/read-all", method: "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func unreadCount() async throws -> Int {
|
||||||
|
let resp: UnreadCountResponse = try await client.request("/notifications/unread-count")
|
||||||
|
return resp.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerPushToken(_ token: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/notifications/push-token", method: "POST", body: ["token": token])
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePushToken(_ token: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/notifications/push-tokens/\(token)", method: "DELETE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnreadCountResponse: Codable {
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
36
AIStudyApp/AIStudyApp/Core/Services/API/QuizAPI.swift
Normal file
36
AIStudyApp/AIStudyApp/Core/Services/API/QuizAPI.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class QuizService {
|
||||||
|
static let shared = QuizService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func generate(knowledgeBaseId: String, sourceType: String? = nil, sourceId: String? = nil, questionCount: Int = 5) async throws -> Quiz {
|
||||||
|
let body = QuizGenerateRequest(
|
||||||
|
knowledgeBaseId: knowledgeBaseId,
|
||||||
|
sourceType: sourceType,
|
||||||
|
sourceId: sourceId,
|
||||||
|
questionCount: questionCount
|
||||||
|
)
|
||||||
|
return try await client.request("/quizzes", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(knowledgeBaseId: String? = nil) async throws -> [Quiz] {
|
||||||
|
var items: [URLQueryItem]? = nil
|
||||||
|
if let kb = knowledgeBaseId { items = [URLQueryItem(name: "knowledgeBaseId", value: kb)] }
|
||||||
|
return try await client.request("/quizzes", queryItems: items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detail(id: String) async throws -> Quiz { try await client.request("/quizzes/\(id)") }
|
||||||
|
func createAttempt(quizId: String) async throws -> QuizAttemptStartResponse { try await client.request("/quizzes/\(quizId)/attempts", method: "POST") }
|
||||||
|
func getAttemptQuestions(attemptId: String) async throws -> [QuizQuestion] { try await client.request("/quizzes/attempts/\(attemptId)/questions") }
|
||||||
|
func start(quizId: String) async throws -> QuizAttempt { try await client.request("/quizzes/\(quizId)/start", method: "POST") }
|
||||||
|
|
||||||
|
func submit(quizId: String, attemptId: String, answers: [QuizAnswerItem]) async throws -> QuizSubmitResponse {
|
||||||
|
let body = QuizSubmitRequest(attemptId: attemptId, answers: answers)
|
||||||
|
return try await client.request("/quizzes/\(quizId)/submit", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func results(quizId: String, attemptId: String) async throws -> QuizAttempt { try await client.request("/quizzes/\(quizId)/results?attemptId=\(attemptId)") }
|
||||||
|
func history() async throws -> [QuizAttempt] { try await client.request("/quizzes/history/list") }
|
||||||
|
}
|
||||||
57
AIStudyApp/AIStudyApp/Core/Services/API/ReadingAPI.swift
Normal file
57
AIStudyApp/AIStudyApp/Core/Services/API/ReadingAPI.swift
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Reading Progress (DTOs)
|
||||||
|
|
||||||
|
struct MaterialReadingProgressDTO: Codable {
|
||||||
|
let status: String?
|
||||||
|
let isMarkedRead: Bool?
|
||||||
|
let lastPosition: ReadingPositionValue?
|
||||||
|
let totalActiveSeconds: Int?
|
||||||
|
let sessionCount: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Service
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ReadingAPIService {
|
||||||
|
static let shared = ReadingAPIService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
// Protocol-compatible: ReadingProgressServicing
|
||||||
|
func getReadingProgress(materialId: String, targetType: String) async throws -> MaterialReadingProgressDTO {
|
||||||
|
try await client.request("/materials/\(materialId)/reading-progress?readingTargetType=\(targetType)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience with default
|
||||||
|
func getReadingProgress(materialId: String, readingTargetType: String = "material") async throws -> MaterialReadingProgressDTO {
|
||||||
|
try await getReadingProgress(materialId: materialId, targetType: readingTargetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadReadingEvents(_ events: [ReadingEventUploadItem]) async throws -> GenericSuccessResponse {
|
||||||
|
let batch = ReadingEventBatchRequest(events: events)
|
||||||
|
return try await client.request("/learning/reading-events/batch", method: "POST", body: batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadReadingEvents(_ batch: ReadingEventBatchRequest) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/learning/reading-events/batch", method: "POST", body: batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getContinueLearning() async throws -> ContinueLearningResponse {
|
||||||
|
try await client.request("/learning/continue")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLearningSummary() async throws -> LearningSummaryResponse {
|
||||||
|
try await client.request("/learning/summary")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLearningTrend(days: Int = 7) async throws -> LearningTrendResponse {
|
||||||
|
try await client.request("/learning/trend?days=\(days)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLearningRecords(cursor: String? = nil, limit: Int = 10, type: String? = nil) async throws -> LearningRecordsResponse {
|
||||||
|
var items: [URLQueryItem] = [URLQueryItem(name: "limit", value: String(limit))]
|
||||||
|
if let c = cursor { items.append(URLQueryItem(name: "cursor", value: c)) }
|
||||||
|
if let t = type { items.append(URLQueryItem(name: "type", value: t)) }
|
||||||
|
return try await client.request("/learning/records", queryItems: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
AIStudyApp/AIStudyApp/Core/Services/API/ReviewAPI.swift
Normal file
24
AIStudyApp/AIStudyApp/Core/Services/API/ReviewAPI.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ReviewService {
|
||||||
|
static let shared = ReviewService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func dueCards() async throws -> [ReviewCard] {
|
||||||
|
try await client.request("/reviews/due")
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit(id: String, rating: String, responseText: String? = nil) async throws -> ReviewCard {
|
||||||
|
var body: [String: String] = ["rating": rating]
|
||||||
|
if let text = responseText { body["responseText"] = text }
|
||||||
|
return try await client.request("/reviews/\(id)/submit", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func history(page: Int = 1, limit: Int = 20) async throws -> [ReviewCard] {
|
||||||
|
try await client.request("/reviews/history", queryItems: [
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
53
AIStudyApp/AIStudyApp/Core/Services/API/UserAPI.swift
Normal file
53
AIStudyApp/AIStudyApp/Core/Services/API/UserAPI.swift
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class UserService {
|
||||||
|
static let shared = UserService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func myProfile() async throws -> UserProfileResponse {
|
||||||
|
try await client.request("/users/me")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProfile(_ dto: UpdateProfileRequest) async throws -> UserProfileResponse {
|
||||||
|
try await client.request("/users/me", method: "PATCH", body: dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePreferences(_ dto: UpdatePreferencesRequest) async throws -> UserPreferences {
|
||||||
|
try await client.request("/users/me/preferences", method: "PATCH", body: dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProfileDetail() async throws -> UserProfileData {
|
||||||
|
try await client.request("/users/me/profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProfileDetail(_ dto: UpdateProfileDataRequest) async throws -> UserProfileData {
|
||||||
|
try await client.request("/users/me/profile", method: "PATCH", body: dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAccountDeletion() async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/users/me/deletion-request", method: "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelDeletion() async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/users/me/deletion-request", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDevices() async throws -> [UserDevice] {
|
||||||
|
try await client.request("/users/me/devices")
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDevice(id: String) async throws -> GenericSuccessResponse {
|
||||||
|
try await client.request("/users/me/devices/\(id)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAssets() async throws -> AssetsResponse {
|
||||||
|
try await client.request("/users/me/assets-summary")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStorage() async throws -> StorageResponse {
|
||||||
|
try await client.request("/users/me/storage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
20
AIStudyApp/AIStudyApp/Core/Services/API/WaitlistAPI.swift
Normal file
20
AIStudyApp/AIStudyApp/Core/Services/API/WaitlistAPI.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WaitlistService {
|
||||||
|
static let shared = WaitlistService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func join(email: String, name: String? = nil) async throws -> WaitlistResponse {
|
||||||
|
let body = WaitlistCreateRequest(email: email, name: name)
|
||||||
|
return try await client.request("/waitlist", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func list() async throws -> [WaitlistEntry] {
|
||||||
|
try await client.request("/waitlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stats() async throws -> WaitlistStats {
|
||||||
|
try await client.request("/waitlist/stats")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,684 +1,27 @@
|
|||||||
//
|
//
|
||||||
// APIService.swift - 各模块 API 调用封装
|
// APIService.swift — 各模块 API 调用封装
|
||||||
|
//
|
||||||
|
// 所有 Service 类已拆分到 Core/Services/API/ 下独立文件:
|
||||||
|
//
|
||||||
|
// AuthAPI.swift → AuthService
|
||||||
|
// UserAPI.swift → UserService
|
||||||
|
// KnowledgeBaseAPI.swift → KnowledgeBaseService
|
||||||
|
// KnowledgeSourceAPI.swift → KnowledgeSourceService
|
||||||
|
// KnowledgeItemAPI.swift → KnowledgeItemService
|
||||||
|
// ActiveRecallAPI.swift → ActiveRecallService
|
||||||
|
// AIAnalysisAPI.swift → AIAnalysisService
|
||||||
|
// LearningSessionAPI.swift → LearningSessionService
|
||||||
|
// ReviewAPI.swift → ReviewService
|
||||||
|
// FocusItemAPI.swift → FocusItemService
|
||||||
|
// ActivityAPI.swift → ActivityService
|
||||||
|
// FeedbackAPI.swift → FeedbackService
|
||||||
|
// ChatAPI.swift → RagChatService
|
||||||
|
// QuizAPI.swift → QuizService
|
||||||
|
// NotificationAPI.swift → NotificationService
|
||||||
|
// MembershipAPI.swift → MembershipService
|
||||||
|
// ImportAPI.swift → DocumentImportService
|
||||||
|
// WaitlistAPI.swift → WaitlistService
|
||||||
|
// ReadingAPI.swift → ReadingAPIService
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Waitlist
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class WaitlistService {
|
|
||||||
static let shared = WaitlistService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func join(email: String, nickname: String?, devices: [String]?,
|
|
||||||
interests: [String]?, painpoint: String?, willingBeta: Bool = true) async throws -> WaitlistEntry {
|
|
||||||
let body = WaitlistCreateRequest(email: email, nickname: nickname, devices: devices,
|
|
||||||
interests: interests, painpoint: painpoint, willingBeta: willingBeta)
|
|
||||||
return try await client.request("/waitlist", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stats() async throws -> WaitlistStats {
|
|
||||||
return try await client.request("/waitlist/stats")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Auth
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class AuthService {
|
|
||||||
static let shared = AuthService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func appleLogin(identityToken: String, authorizationCode: String? = nil, givenName: String?, familyName: String?, nonce: String? = nil) async throws -> AuthResponse {
|
|
||||||
let body = AppleAuthRequest(
|
|
||||||
identityToken: identityToken,
|
|
||||||
authorizationCode: authorizationCode,
|
|
||||||
fullName: givenName != nil
|
|
||||||
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
|
|
||||||
: nil,
|
|
||||||
nonce: nonce
|
|
||||||
)
|
|
||||||
return try await client.request("/auth/apple", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - User
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class UserService {
|
|
||||||
static let shared = UserService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func myProfile() async throws -> UserProfileResponse {
|
|
||||||
return try await client.request("/users/me")
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateProfile(_ dto: UpdateProfileRequest) async throws -> UserProfileResponse {
|
|
||||||
return try await client.request("/users/me", method: "PATCH", body: dto)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatePreferences(_ dto: UpdatePreferencesRequest) async throws -> UserPreferences {
|
|
||||||
return try await client.request("/users/me/preferences", method: "PATCH", body: dto)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getProfileDetail() async throws -> UserProfileData {
|
|
||||||
return try await client.request("/users/me/profile")
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateProfileDetail(_ dto: UpdateProfileDataRequest) async throws -> UserProfileData {
|
|
||||||
return try await client.request("/users/me/profile", method: "PATCH", body: dto)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestAccountDeletion() async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/users/me/deletion-request", method: "POST")
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelDeletion() async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/users/me/deletion-request", method: "DELETE")
|
|
||||||
}
|
|
||||||
|
|
||||||
func listDevices() async throws -> [UserDevice] {
|
|
||||||
return try await client.request("/users/me/devices")
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeDevice(id: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/users/me/devices/\(id)", method: "DELETE")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchAssets() async throws -> AssetsResponse {
|
|
||||||
return try await client.request("/users/me/assets-summary")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchStorage() async throws -> StorageResponse {
|
|
||||||
return try await client.request("/users/me/storage")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UserDevice: Codable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let deviceName: String?
|
|
||||||
let deviceType: String?
|
|
||||||
let lastSeenAt: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Knowledge Base
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class KnowledgeBaseService {
|
|
||||||
static let shared = KnowledgeBaseService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func list(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
|
|
||||||
return try await client.request("/knowledge-bases", queryItems: [
|
|
||||||
URLQueryItem(name: "page", value: String(page)),
|
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func create(title: String, description: String?, coverKey: String? = nil) async throws -> KnowledgeBase {
|
|
||||||
let body = CreateKnowledgeBaseRequest(title: title, description: description, coverKey: coverKey)
|
|
||||||
return try await client.request("/knowledge-bases", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func detail(id: String) async throws -> KnowledgeBase {
|
|
||||||
return try await client.request("/knowledge-bases/\(id)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func update(id: String, title: String?, description: String?) async throws -> KnowledgeBase {
|
|
||||||
let body = CreateKnowledgeBaseRequest(title: title ?? "", description: description)
|
|
||||||
return try await client.request("/knowledge-bases/\(id)", method: "PATCH", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listSubscribed(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
|
|
||||||
return try await client.request("/knowledge-bases/subscribed", queryItems: [
|
|
||||||
URLQueryItem(name: "page", value: String(page)),
|
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func discover(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
|
|
||||||
return try await client.request("/knowledge-bases", queryItems: [
|
|
||||||
URLQueryItem(name: "visibility", value: "public"),
|
|
||||||
URLQueryItem(name: "page", value: String(page)),
|
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribe(kbId: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/subscribe", method: "POST")
|
|
||||||
}
|
|
||||||
|
|
||||||
func unsubscribe(kbId: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/subscribe", method: "DELETE")
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete(id: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/knowledge-bases/\(id)", method: "DELETE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Knowledge Folder
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class KnowledgeFolderService {
|
|
||||||
static let shared = KnowledgeFolderService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func list(kbId: String) async throws -> [KnowledgeFolder] {
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/folders")
|
|
||||||
}
|
|
||||||
|
|
||||||
func create(kbId: String, name: String, parentId: String? = nil) async throws -> KnowledgeFolder {
|
|
||||||
let body = CreateFolderRequest(name: name, parentId: parentId)
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/folders", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func update(kbId: String, folderId: String, name: String) async throws -> KnowledgeFolder {
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/folders/\(folderId)", method: "PATCH", body: ["name": name])
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete(kbId: String, folderId: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/folders/\(folderId)", method: "DELETE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Knowledge Items
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class KnowledgeItemService {
|
|
||||||
static let shared = KnowledgeItemService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func list(knowledgeBaseId: String, sortBy: String? = nil, order: String? = nil) async throws -> [KnowledgeItem] {
|
|
||||||
var items = [URLQueryItem(name: "knowledgeBaseId", value: knowledgeBaseId)]
|
|
||||||
if let s = sortBy { items.append(URLQueryItem(name: "sortBy", value: s)) }
|
|
||||||
if let o = order { items.append(URLQueryItem(name: "order", value: o)) }
|
|
||||||
return try await client.request("/knowledge-items", queryItems: items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func detail(id: String) async throws -> KnowledgeItem {
|
|
||||||
return try await client.request("/knowledge-items/\(id)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func create(knowledgeBaseId: String, title: String, content: String?, itemType: String? = nil) async throws -> KnowledgeItem {
|
|
||||||
let body = CreateKnowledgeItemRequest(
|
|
||||||
knowledgeBaseId: knowledgeBaseId,
|
|
||||||
title: title,
|
|
||||||
content: content,
|
|
||||||
itemType: itemType
|
|
||||||
)
|
|
||||||
return try await client.request("/knowledge-items", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func update(id: String, title: String?, content: String?, summary: String?) async throws -> KnowledgeItem {
|
|
||||||
let body = UpdateKnowledgeItemRequest(title: title, content: content, summary: summary)
|
|
||||||
return try await client.request("/knowledge-items/\(id)", method: "PATCH", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete(id: String) async throws {
|
|
||||||
let _: GenericSuccessResponse = try await client.request("/knowledge-items/\(id)", method: "DELETE")
|
|
||||||
}
|
|
||||||
|
|
||||||
func batchDelete(ids: [String]) async throws -> BatchDeleteResponse {
|
|
||||||
return try await client.request("/knowledge-items/batch-delete", method: "POST", body: ["ids": ids])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Active Recall
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class ActiveRecallService {
|
|
||||||
static let shared = ActiveRecallService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func questions(page: Int = 1, limit: Int = 20) async throws -> [ActiveRecallQuestion] {
|
|
||||||
return try await client.request("/active-recalls", queryItems: [
|
|
||||||
URLQueryItem(name: "page", value: String(page)),
|
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func submit(questionId: String, answerText: String) async throws -> ActiveRecallAnswer {
|
|
||||||
let body = SubmitAnswerRequest(answerText: answerText)
|
|
||||||
return try await client.request("/active-recalls/\(questionId)/submit", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - AI Analysis
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class AIAnalysisService {
|
|
||||||
static let shared = AIAnalysisService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func analyze(questionText: String, knowledgeItemContent: String, userAnswer: String) async throws -> AIAnalysisResult {
|
|
||||||
let body = AIAnalysisRequest(
|
|
||||||
questionText: questionText,
|
|
||||||
knowledgeItemContent: knowledgeItemContent,
|
|
||||||
userAnswer: userAnswer,
|
|
||||||
text: nil,
|
|
||||||
type: nil
|
|
||||||
)
|
|
||||||
return try await client.request("/ai-analysis", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Learning Sessions
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class LearningSessionService {
|
|
||||||
static let shared = LearningSessionService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func list(page: Int = 1, limit: Int = 20) async throws -> [LearningSession] {
|
|
||||||
return try await client.request("/learning-sessions", queryItems: [
|
|
||||||
URLQueryItem(name: "page", value: String(page)),
|
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func start(knowledgeBaseId: String? = nil, knowledgeItemId: String? = nil, mode: String? = nil) async throws -> LearningSession {
|
|
||||||
let body = CreateLearningSessionRequest(
|
|
||||||
knowledgeBaseId: knowledgeBaseId,
|
|
||||||
knowledgeItemId: knowledgeItemId,
|
|
||||||
mode: mode
|
|
||||||
)
|
|
||||||
return try await client.request("/learning-sessions", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func end(id: String) async throws -> LearningSession {
|
|
||||||
return try await client.request("/learning-sessions/\(id)/end", method: "POST")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Review
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class ReviewService {
|
|
||||||
static let shared = ReviewService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func dueCards() async throws -> [ReviewCard] {
|
|
||||||
return try await client.request("/reviews/due")
|
|
||||||
}
|
|
||||||
|
|
||||||
func submit(id: String, rating: String, responseText: String? = nil) async throws -> GenericSuccessResponse {
|
|
||||||
let body = SubmitReviewRequest(rating: rating, responseText: responseText)
|
|
||||||
return try await client.request("/reviews/\(id)/submit", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateCards() async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/reviews/generate-cards", method: "POST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Focus Items
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class FocusItemService {
|
|
||||||
static let shared = FocusItemService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func list(page: Int = 1, limit: Int = 20) async throws -> [FocusItem] {
|
|
||||||
return try await client.request("/focus-items", queryItems: [
|
|
||||||
URLQueryItem(name: "page", value: String(page)),
|
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func update(id: String, masteryScore: Int? = nil, title: String? = nil) async throws -> FocusItem {
|
|
||||||
let body = FocusItemUpdateRequest(masteryScore: masteryScore, title: title)
|
|
||||||
return try await client.request("/focus-items/\(id)", method: "PATCH", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func complete(id: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/focus-items/\(id)/complete", method: "POST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Activity & Stats
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class ActivityService {
|
|
||||||
static let shared = ActivityService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func summary() async throws -> ActivitySummary {
|
|
||||||
return try await client.request("/activity/summary")
|
|
||||||
}
|
|
||||||
|
|
||||||
func heatmap() async throws -> [String: Int] {
|
|
||||||
return try await client.request("/activity/heatmap")
|
|
||||||
}
|
|
||||||
|
|
||||||
func trend(days: Int = 7) async throws -> [ActivityTrend] {
|
|
||||||
return try await client.request("/activity/trend?days=\(days)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func streak() async throws -> ActivityStreak {
|
|
||||||
return try await client.request("/activity/streak")
|
|
||||||
}
|
|
||||||
|
|
||||||
func recommendations() async throws -> [ActivityRecommendation] {
|
|
||||||
return try await client.request("/activity/recommendations")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Feedback
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class FeedbackService {
|
|
||||||
static let shared = FeedbackService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func submit(category: String = "general", content: String, email: String? = nil) async throws -> FeedbackData {
|
|
||||||
let body = FeedbackCreateRequest(category: category, content: content, email: email)
|
|
||||||
return try await client.request("/feedback", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - RAG Chat
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class RagChatService {
|
|
||||||
static let shared = RagChatService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func createSession(ctx: ChatEntryContext, forceCreate: Bool = false) async throws -> ChatSession {
|
|
||||||
let body = CreateSessionRequest(
|
|
||||||
scopeType: ctx.scopeType.rawValue,
|
|
||||||
scopeId: ctx.scopeId,
|
|
||||||
parentKnowledgeBaseId: ctx.parentKnowledgeBaseId,
|
|
||||||
createdFrom: ctx.createdFrom,
|
|
||||||
title: nil,
|
|
||||||
forceCreate: forceCreate ? true : nil
|
|
||||||
)
|
|
||||||
print("[RagChat] POST /rag-chat/sessions — scopeType=\(body.scopeType), scopeId=\(body.scopeId ?? "nil"), parentKB=\(body.parentKnowledgeBaseId ?? "nil"), forceCreate=\(forceCreate)")
|
|
||||||
do {
|
|
||||||
let session = try await client.request("/rag-chat/sessions", method: "POST", body: body) as ChatSession
|
|
||||||
print("[RagChat] createSession OK — id=\(session.id), scopeType=\(session.scopeType ?? "nil")")
|
|
||||||
return session
|
|
||||||
} catch {
|
|
||||||
print("[RagChat] createSession FAILED: \(error)")
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func listSessions(scopeType: String? = nil, scopeId: String? = nil, parentKnowledgeBaseId: String? = nil) async throws -> [ChatSession] {
|
|
||||||
var items: [URLQueryItem] = []
|
|
||||||
if let st = scopeType { items.append(URLQueryItem(name: "scopeType", value: st)) }
|
|
||||||
if let si = scopeId { items.append(URLQueryItem(name: "scopeId", value: si)) }
|
|
||||||
if let pk = parentKnowledgeBaseId { items.append(URLQueryItem(name: "parentKnowledgeBaseId", value: pk)) }
|
|
||||||
let page: PaginatedResponse<ChatSession> = try await client.request("/rag-chat/sessions", queryItems: items.isEmpty ? nil : items)
|
|
||||||
return page.data
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMessages(sessionId: String) async throws -> [ChatMessage] {
|
|
||||||
return try await client.request("/rag-chat/sessions/\(sessionId)/messages")
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendMessage(sessionId: String, content: String) async throws -> SendMessageResponse {
|
|
||||||
let body = SendMessageRequest(content: content)
|
|
||||||
return try await client.request("/rag-chat/sessions/\(sessionId)/messages", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateSession(id: String, dto: UpdateChatSessionRequest) async throws -> ChatSession {
|
|
||||||
return try await client.request("/rag-chat/sessions/\(id)", method: "PATCH", body: dto)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteSession(id: String) async throws {
|
|
||||||
let _: GenericSuccessResponse = try await client.request("/rag-chat/sessions/\(id)", method: "DELETE")
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SSEChunk: Decodable {
|
|
||||||
let type: String
|
|
||||||
let content: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendMessageStream(sessionId: String, content: String) -> AsyncThrowingStream<SSEChunk, Error> {
|
|
||||||
AsyncThrowingStream { continuation in
|
|
||||||
Task {
|
|
||||||
let token = await APIClient.shared.getToken() ?? ""
|
|
||||||
let url = URL(string: "\(APIConfig.baseURL)/rag-chat/sessions/\(sessionId)/stream")!
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
||||||
request.httpBody = try? JSONEncoder().encode(SendMessageRequest(content: content))
|
|
||||||
|
|
||||||
let session = URLSession(configuration: .default)
|
|
||||||
let (bytes, _) = try await session.bytes(for: request)
|
|
||||||
|
|
||||||
// Read SSE lines properly — URLSession.bytes is character-based (UTF-8 safe)
|
|
||||||
var lineBuffer = ""
|
|
||||||
for try await line in bytes.lines {
|
|
||||||
if line.hasPrefix("data: "), let jsonData = line.dropFirst(6).data(using: .utf8) {
|
|
||||||
if let chunk = try? JSONDecoder().decode(SSEChunk.self, from: jsonData) {
|
|
||||||
continuation.yield(chunk)
|
|
||||||
if chunk.type == "done" || chunk.type == "error" {
|
|
||||||
continuation.finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continuation.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Document Import
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class DocumentImportService {
|
|
||||||
static let shared = DocumentImportService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func create(knowledgeBaseId: String, fileName: String? = nil, sourceType: String? = "file", rawText: String? = nil) async throws -> ImportStatusResponse {
|
|
||||||
let body = CreateImportRequest(knowledgeBaseId: knowledgeBaseId, fileName: fileName, sourceType: sourceType, rawText: rawText)
|
|
||||||
return try await client.request("/imports", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getStatus(id: String) async throws -> ImportStatusResponse {
|
|
||||||
return try await client.request("/imports/\(id)/status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Import Candidate
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class ImportCandidateService {
|
|
||||||
static let shared = ImportCandidateService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func listBySource(sourceId: String) async throws -> [ImportCandidate] {
|
|
||||||
return try await client.request("/knowledge-sources/\(sourceId)/import-candidates")
|
|
||||||
}
|
|
||||||
|
|
||||||
func detail(id: String) async throws -> ImportCandidate {
|
|
||||||
return try await client.request("/import-candidates/\(id)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func update(id: String, title: String?, content: String?) async throws -> ImportCandidate {
|
|
||||||
var body: [String: String] = [:]
|
|
||||||
if let t = title { body["title"] = t }
|
|
||||||
if let c = content { body["content"] = c }
|
|
||||||
return try await client.request("/import-candidates/\(id)", method: "PATCH", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func accept(id: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/import-candidates/\(id)/accept", method: "POST")
|
|
||||||
}
|
|
||||||
|
|
||||||
func reject(id: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/import-candidates/\(id)/reject", method: "POST")
|
|
||||||
}
|
|
||||||
|
|
||||||
func batchAccept(sourceId: String) async throws -> GenericSuccessResponse {
|
|
||||||
let body = BatchAcceptRequest(sourceId: sourceId)
|
|
||||||
return try await client.request("/import-candidates/batch-accept", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Knowledge Source
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class KnowledgeSourceService {
|
|
||||||
static let shared = KnowledgeSourceService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func list(kbId: String, sortBy: String? = nil, order: String? = nil) async throws -> [KnowledgeSource] {
|
|
||||||
var items = [URLQueryItem]()
|
|
||||||
if let s = sortBy { items.append(URLQueryItem(name: "sortBy", value: s)) }
|
|
||||||
if let o = order { items.append(URLQueryItem(name: "order", value: o)) }
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/sources", queryItems: items.isEmpty ? nil : items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func detail(kbId: String, id: String) async throws -> KnowledgeSource {
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/sources/\(id)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(kbId: String, fileId: String? = nil, type: String? = nil, title: String? = nil, originalFilename: String? = nil, mimeType: String? = nil, sizeBytes: Int? = nil, originalObjectKey: String? = nil) async throws -> KnowledgeSource {
|
|
||||||
let body = AddSourceRequest(fileId: fileId, type: type, title: title, originalFilename: originalFilename, mimeType: mimeType, sizeBytes: sizeBytes, originalObjectKey: originalObjectKey)
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/sources", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete(kbId: String, id: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/sources/\(id)", method: "DELETE")
|
|
||||||
}
|
|
||||||
|
|
||||||
func parse(kbId: String, sourceId: String) async throws -> ParseResponse {
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/parse", method: "POST")
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateKnowledge(kbId: String, sourceId: String) async throws -> GenerateKnowledgeResponse {
|
|
||||||
return try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/generate-knowledge", method: "POST")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Quiz
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class QuizService {
|
|
||||||
static let shared = QuizService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func generate(knowledgeBaseId: String, questionCount: Int = 5) async throws -> Quiz {
|
|
||||||
let body = QuizGenerateRequest(knowledgeBaseId: knowledgeBaseId, questionCount: questionCount)
|
|
||||||
return try await client.request("/quizzes", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func list(knowledgeBaseId: String? = nil) async throws -> [Quiz] {
|
|
||||||
var items: [URLQueryItem]? = nil
|
|
||||||
if let kb = knowledgeBaseId { items = [URLQueryItem(name: "knowledgeBaseId", value: kb)] }
|
|
||||||
return try await client.request("/quizzes", queryItems: items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func detail(id: String) async throws -> Quiz { return try await client.request("/quizzes/\(id)") }
|
|
||||||
|
|
||||||
// M-QUIZ-01-03: 使用新 Attempt API
|
|
||||||
func createAttempt(quizId: String) async throws -> QuizAttemptStartResponse {
|
|
||||||
return try await client.request("/quizzes/\(quizId)/attempts", method: "POST")
|
|
||||||
}
|
|
||||||
|
|
||||||
// M-QUIZ-01-03: 按 Attempt 加载脱敏题目
|
|
||||||
func getAttemptQuestions(attemptId: String) async throws -> [QuizQuestion] {
|
|
||||||
return try await client.request("/quizzes/attempts/\(attemptId)/questions")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保留旧端点兼容
|
|
||||||
func start(quizId: String) async throws -> QuizAttempt { return try await client.request("/quizzes/\(quizId)/start", method: "POST") }
|
|
||||||
|
|
||||||
func submit(quizId: String, attemptId: String, answers: [QuizAnswerItem]) async throws -> QuizSubmitResponse {
|
|
||||||
let body = QuizSubmitRequest(attemptId: attemptId, answers: answers)
|
|
||||||
return try await client.request("/quizzes/\(quizId)/submit", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
func results(quizId: String, attemptId: String) async throws -> QuizAttempt { return try await client.request("/quizzes/\(quizId)/results?attemptId=\(attemptId)") }
|
|
||||||
func history() async throws -> [QuizAttempt] { return try await client.request("/quizzes/history/list") }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notifications
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class NotificationService {
|
|
||||||
static let shared = NotificationService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func list(page: Int = 1, limit: Int = 20) async throws -> [NotificationItem] {
|
|
||||||
return try await client.request("/notifications", queryItems: [
|
|
||||||
URLQueryItem(name: "page", value: String(page)),
|
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
func markRead(id: String) async throws -> NotificationItem {
|
|
||||||
return try await client.request("/notifications/\(id)/read", method: "PATCH")
|
|
||||||
}
|
|
||||||
|
|
||||||
func markAllRead() async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/notifications/read-all", method: "POST")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPreferences() async throws -> NotificationPreferences {
|
|
||||||
return try await client.request("/notifications/preferences")
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatePreferences(_ dto: NotificationPreferences) async throws -> NotificationPreferences {
|
|
||||||
return try await client.request("/notifications/preferences", method: "PATCH", body: dto)
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerPushToken(_ token: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/notifications/push-token", method: "POST", body: ["token": token])
|
|
||||||
}
|
|
||||||
|
|
||||||
func removePushToken(_ token: String) async throws -> GenericSuccessResponse {
|
|
||||||
return try await client.request("/notifications/push-tokens/\(token)", method: "DELETE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Membership (M-MEMBER-01-09)
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class MembershipService {
|
|
||||||
static let shared = MembershipService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
func getPlans() async throws -> MembershipPlanResponse {
|
|
||||||
try await client.request("/membership/plans")
|
|
||||||
}
|
|
||||||
|
|
||||||
func myMembership() async throws -> MyMembershipResponse {
|
|
||||||
try await client.request("/membership/me")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUsage() async throws -> UsageSummary {
|
|
||||||
try await client.request("/membership/usage")
|
|
||||||
}
|
|
||||||
|
|
||||||
func submitTransaction(signedTransactionInfo: String) async throws -> TransactionSubmitResponse {
|
|
||||||
try await client.request(
|
|
||||||
"/membership/apple/transactions",
|
|
||||||
method: "POST",
|
|
||||||
body: ["signedTransactionInfo": signedTransactionInfo] as [String: String]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NotificationPreferences: Codable {
|
|
||||||
var reviewReminder: Bool?
|
|
||||||
var newFeatures: Bool?
|
|
||||||
var systemNotice: Bool?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FocusItemUpdateRequest: Codable {
|
|
||||||
let masteryScore: Int?
|
|
||||||
let title: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FocusItemCompleteRequest: Codable {
|
|
||||||
let id: String
|
|
||||||
}
|
|
||||||
|
|||||||
@ -7,8 +7,19 @@ struct MaterialPathResolver {
|
|||||||
static func resolve(materialId: String, apiType: String?, title: String?) -> (filePath: String, materialType: MaterialType)? {
|
static func resolve(materialId: String, apiType: String?, title: String?) -> (filePath: String, materialType: MaterialType)? {
|
||||||
let mt = materialTypeFrom(apiType: apiType, title: title)
|
let mt = materialTypeFrom(apiType: apiType, title: title)
|
||||||
|
|
||||||
// 1. knowledge_source: look up by source ID in local KnowledgeSourceService
|
// 1. knowledge_source: check actual download paths first (tmp/), then legacy paths
|
||||||
if apiType == "knowledge_source" {
|
if apiType == "knowledge_source" {
|
||||||
|
// Priority 1: tmp/ — where MaterialReaderView and KnowledgeDetailVM actually download to
|
||||||
|
let tmpDir = FileManager.default.temporaryDirectory
|
||||||
|
if let files = try? FileManager.default.contentsOfDirectory(at: tmpDir, includingPropertiesForKeys: nil) {
|
||||||
|
for f in files {
|
||||||
|
let name = f.lastPathComponent
|
||||||
|
if name.hasPrefix("source_\(materialId).") || name.hasPrefix("kd_\(materialId).") {
|
||||||
|
return (f.path, mt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Priority 2: legacy path Documents/knowledge_sources/<id>/
|
||||||
if let path = resolveKnowledgeSource(id: materialId) {
|
if let path = resolveKnowledgeSource(id: materialId) {
|
||||||
return (path, mt)
|
return (path, mt)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,131 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - Reading API Service
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class ReadingAPIService {
|
|
||||||
static let shared = ReadingAPIService()
|
|
||||||
private let client = APIClient.shared
|
|
||||||
|
|
||||||
/// POST /learning/reading-events/batch
|
|
||||||
func uploadReadingEvents(_ events: [ReadingEventUploadItem]) async throws -> ReadingEventBatchResponse {
|
|
||||||
let body = ReadingEventBatchRequest(events: events)
|
|
||||||
return try await client.request("/learning/reading-events/batch", method: "POST", body: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /materials/:id/reading-progress
|
|
||||||
func getReadingProgress(materialId: String, targetType: String) async throws -> MaterialReadingProgressDTO {
|
|
||||||
return try await client.request(
|
|
||||||
"/materials/\(materialId)/reading-progress",
|
|
||||||
queryItems: [URLQueryItem(name: "readingTargetType", value: targetType)]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /learning/continue
|
|
||||||
func getContinueLearning() async throws -> ContinueLearningResponse {
|
|
||||||
return try await client.request("/learning/continue")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /learning/summary
|
|
||||||
func getLearningSummary() async throws -> LearningSummaryResponse {
|
|
||||||
return try await client.request("/learning/summary")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /learning/trend?days=7
|
|
||||||
func getLearningTrend(days: Int = 7) async throws -> LearningTrendResponse {
|
|
||||||
return try await client.request(
|
|
||||||
"/learning/trend",
|
|
||||||
queryItems: [URLQueryItem(name: "days", value: String(days))]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /learning/records
|
|
||||||
func getLearningRecords(cursor: String? = nil, limit: Int = 20, type: String? = nil) async throws -> LearningRecordsResponse {
|
|
||||||
var items = [URLQueryItem(name: "limit", value: String(limit))]
|
|
||||||
if let c = cursor { items.append(URLQueryItem(name: "cursor", value: c)) }
|
|
||||||
if let t = type { items.append(URLQueryItem(name: "type", value: t)) }
|
|
||||||
return try await client.request("/learning/records", queryItems: items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Response DTOs
|
|
||||||
|
|
||||||
/// Codable wrapper for API's lastPosition (JSON object matching the server's ReadingPosition schema).
|
|
||||||
struct ReadingPositionValue: Codable {
|
|
||||||
let type: String?
|
|
||||||
let blockId: String?
|
|
||||||
let lineNumber: UInt32?
|
|
||||||
let pageNumber: UInt32?
|
|
||||||
let chapterId: String?
|
|
||||||
let scrollProgress: Float?
|
|
||||||
let pageProgress: Float?
|
|
||||||
let overallProgress: Float?
|
|
||||||
let zoomScale: Float?
|
|
||||||
let offsetX: Float?
|
|
||||||
let offsetY: Float?
|
|
||||||
let chapterProgress: Float?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MaterialReadingProgressDTO: Codable {
|
|
||||||
let status: String
|
|
||||||
let lastPosition: ReadingPositionValue?
|
|
||||||
let lastProgress: Float?
|
|
||||||
let totalActiveSeconds: Int
|
|
||||||
let isMarkedRead: Bool
|
|
||||||
let sessionCount: Int?
|
|
||||||
let firstOpenedAt: String?
|
|
||||||
let lastReadAt: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContinueLearningResponse: Codable {
|
|
||||||
let type: String?
|
|
||||||
let materialId: String?
|
|
||||||
let title: String?
|
|
||||||
let lastProgress: Float?
|
|
||||||
let totalActiveSeconds: Int?
|
|
||||||
let lastReadAt: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LearningSummaryResponse: Codable {
|
|
||||||
let todaySeconds: Int
|
|
||||||
let weekSeconds: Int
|
|
||||||
let totalSeconds: Int
|
|
||||||
let activeDays: Int
|
|
||||||
let sessionsCount: Int
|
|
||||||
let materialsReadCount: Int
|
|
||||||
let markedReadCount: Int
|
|
||||||
let dailyAverageSeconds: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LearningTrendResponse: Codable {
|
|
||||||
let days: Int
|
|
||||||
let series: [TrendPoint]
|
|
||||||
|
|
||||||
struct TrendPoint: Codable {
|
|
||||||
let date: String
|
|
||||||
let value: Int
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LearningRecordsResponse: Codable {
|
|
||||||
let items: [RecordItem]
|
|
||||||
let nextCursor: String?
|
|
||||||
|
|
||||||
struct RecordItem: Codable {
|
|
||||||
let id: String
|
|
||||||
let recordType: String
|
|
||||||
let title: String
|
|
||||||
let description: String?
|
|
||||||
let durationSeconds: Int
|
|
||||||
let occurredAt: String
|
|
||||||
let metadata: RecordMetadata?
|
|
||||||
let createdAt: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RecordMetadata: Codable {
|
|
||||||
let materialId: String?
|
|
||||||
let readingTargetType: String?
|
|
||||||
let knowledgeBaseId: String?
|
|
||||||
let totalActiveSeconds: Int?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -16,17 +16,19 @@ struct ReadingRuntimeSession {
|
|||||||
protocol ReadingRuntimeAdapter {
|
protocol ReadingRuntimeAdapter {
|
||||||
// ── Session Lifecycle ──
|
// ── Session Lifecycle ──
|
||||||
|
|
||||||
func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String
|
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String
|
||||||
func pauseSession(_ sessionId: String) throws
|
func pauseSession(_ sessionId: String) throws
|
||||||
func resumeSession(_ sessionId: String) throws
|
func resumeSession(_ sessionId: String) throws
|
||||||
func closeSession(_ sessionId: String) throws
|
func closeSession(_ sessionId: String, timestampMs: Int64) throws
|
||||||
|
|
||||||
// ── Event Push ──
|
// ── Event Push ──
|
||||||
|
|
||||||
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2
|
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2
|
||||||
func pushClosed(sessionId: String, materialId: String, delta: UInt32, timestampMs: Int64) throws -> ReadingEventV2
|
func pushClosed(sessionId: String, materialId: String, delta: UInt32, timestampMs: Int64) throws -> ReadingEventV2
|
||||||
func pushPositionChanged(sessionId: String, materialId: String, position: ReadingPosition, timestampMs: Int64) throws -> ReadingEventV2
|
func pushPositionChanged(sessionId: String, materialId: String, position: ReadingPosition, timestampMs: Int64) throws -> ReadingEventV2
|
||||||
|
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2
|
||||||
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2
|
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2
|
||||||
|
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2
|
||||||
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2
|
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2
|
||||||
|
|
||||||
// ── Buffer Management ──
|
// ── Buffer Management ──
|
||||||
@ -36,12 +38,14 @@ protocol ReadingRuntimeAdapter {
|
|||||||
func markFailed(_ eventIds: [String]) -> UInt32
|
func markFailed(_ eventIds: [String]) -> UInt32
|
||||||
func reloadStaleEvents() -> UInt32
|
func reloadStaleEvents() -> UInt32
|
||||||
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32
|
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32
|
||||||
|
func runtimeComputeFileFingerprint(filePath: String) throws -> String
|
||||||
|
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Default Implementation (Rust FFI)
|
// MARK: - Default Implementation (Rust FFI)
|
||||||
|
|
||||||
final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
||||||
func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String {
|
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
|
||||||
let result = try startReadingSessionV2(material: material, timestampMs: timestampMs)
|
let result = try startReadingSessionV2(material: material, timestampMs: timestampMs)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@ -54,8 +58,8 @@ final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
try resumeReadingSessionV2(sessionId: sessionId)
|
try resumeReadingSessionV2(sessionId: sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func closeSession(_ sessionId: String) throws {
|
func closeSession(_ sessionId: String, timestampMs: Int64) throws {
|
||||||
try closeReadingSessionV2(sessionId: sessionId)
|
try closeReadingSessionV2(sessionId: sessionId, timestampMs: timestampMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
@ -70,10 +74,18 @@ final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
return try pushPositionChangedV2(sessionId: sessionId, materialId: materialId, position: position, timestampMs: timestampMs)
|
return try pushPositionChangedV2(sessionId: sessionId, materialId: materialId, position: position, timestampMs: timestampMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
return try pushCheckpointChangedV2(sessionId: sessionId, materialId: materialId, checkpoint: checkpoint, timestampMs: timestampMs)
|
||||||
|
}
|
||||||
|
|
||||||
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
|
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
return try pushHeartbeatV2(sessionId: sessionId, materialId: materialId, activeSecondsDelta: delta, position: position, timestampMs: timestampMs)
|
return try pushHeartbeatV2(sessionId: sessionId, materialId: materialId, activeSecondsDelta: delta, position: position, timestampMs: timestampMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
return try pushHeartbeatCheckpointV2(sessionId: sessionId, materialId: materialId, activeSecondsDelta: delta, checkpoint: checkpoint, timestampMs: timestampMs)
|
||||||
|
}
|
||||||
|
|
||||||
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
return try pushMarkedAsReadV2(sessionId: sessionId, materialId: materialId, timestampMs: timestampMs)
|
return try pushMarkedAsReadV2(sessionId: sessionId, materialId: materialId, timestampMs: timestampMs)
|
||||||
}
|
}
|
||||||
@ -97,6 +109,19 @@ final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 {
|
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 {
|
||||||
return cleanupStaleSessionsFfi(nowMs: nowMs, maxAgeMs: maxAgeMs)
|
return cleanupStaleSessionsFfi(nowMs: nowMs, maxAgeMs: maxAgeMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
|
||||||
|
return try computeFileFingerprint(filePath: filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
|
||||||
|
return buildRepresentationFingerprint(
|
||||||
|
sourceFingerprint: sourceFingerprint,
|
||||||
|
rendererKind: rendererKind,
|
||||||
|
parserVersion: parserVersion,
|
||||||
|
schemaVersion: schemaVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - V1 Adapter (backward compatibility)
|
// MARK: - V1 Adapter (backward compatibility)
|
||||||
@ -112,7 +137,7 @@ final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
||||||
|
|
||||||
func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String {
|
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
|
||||||
let uuid = UUID().uuidString
|
let uuid = UUID().uuidString
|
||||||
let event = ReadingEvent.materialOpened(
|
let event = ReadingEvent.materialOpened(
|
||||||
materialId: material.materialId,
|
materialId: material.materialId,
|
||||||
@ -126,7 +151,7 @@ final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
|
|
||||||
func resumeSession(_ sessionId: String) throws { /* V1: no-op */ }
|
func resumeSession(_ sessionId: String) throws { /* V1: no-op */ }
|
||||||
|
|
||||||
func closeSession(_ sessionId: String) throws { /* V1: no-op */ }
|
func closeSession(_ sessionId: String, timestampMs: Int64) throws { /* V1: no-op */ }
|
||||||
|
|
||||||
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
||||||
@ -140,10 +165,18 @@ final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
|
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
|
||||||
}
|
}
|
||||||
@ -167,6 +200,14 @@ final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
func reloadStaleEvents() -> UInt32 { return 0 }
|
func reloadStaleEvents() -> UInt32 { return 0 }
|
||||||
|
|
||||||
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { return 0 }
|
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { return 0 }
|
||||||
|
|
||||||
|
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
|
||||||
|
throw ReadingRuntimeAdapterError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
|
||||||
|
return "\(rendererKind)-\(parserVersion)-\(schemaVersion)-\(sourceFingerprint)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Noop Adapter (test / fallback)
|
// MARK: - Noop Adapter (test / fallback)
|
||||||
@ -176,7 +217,7 @@ final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class NoopReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
final class NoopReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
||||||
|
|
||||||
func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String {
|
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
|
||||||
return "noop-\(UUID().uuidString)"
|
return "noop-\(UUID().uuidString)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,7 +225,7 @@ final class NoopReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
|
|
||||||
func resumeSession(_ sessionId: String) throws { }
|
func resumeSession(_ sessionId: String) throws { }
|
||||||
|
|
||||||
func closeSession(_ sessionId: String) throws { }
|
func closeSession(_ sessionId: String, timestampMs: Int64) throws { }
|
||||||
|
|
||||||
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
throw ReadingRuntimeAdapterError.unavailable
|
throw ReadingRuntimeAdapterError.unavailable
|
||||||
@ -198,10 +239,18 @@ final class NoopReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
throw ReadingRuntimeAdapterError.unavailable
|
throw ReadingRuntimeAdapterError.unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
throw ReadingRuntimeAdapterError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
|
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
throw ReadingRuntimeAdapterError.unavailable
|
throw ReadingRuntimeAdapterError.unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
throw ReadingRuntimeAdapterError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
throw ReadingRuntimeAdapterError.unavailable
|
throw ReadingRuntimeAdapterError.unavailable
|
||||||
}
|
}
|
||||||
@ -215,6 +264,14 @@ final class NoopReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
|||||||
func reloadStaleEvents() -> UInt32 { return 0 }
|
func reloadStaleEvents() -> UInt32 { return 0 }
|
||||||
|
|
||||||
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { return 0 }
|
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { return 0 }
|
||||||
|
|
||||||
|
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
|
||||||
|
throw ReadingRuntimeAdapterError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
|
||||||
|
return "\(rendererKind)-\(parserVersion)-\(schemaVersion)-\(sourceFingerprint)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Runtime Error
|
// MARK: - Runtime Error
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
3066
AIStudyApp/AIStudyApp/Core/Services/zx_document.swift.bak
Normal file
3066
AIStudyApp/AIStudyApp/Core/Services/zx_document.swift.bak
Normal file
File diff suppressed because it is too large
Load Diff
@ -248,6 +248,11 @@ typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureRes
|
|||||||
uint32_t uniffi_zx_document_ffi_fn_func_ack_events_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
|
uint32_t uniffi_zx_document_ffi_fn_func_ack_events_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_BUILD_REPRESENTATION_FINGERPRINT
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_BUILD_REPRESENTATION_FINGERPRINT
|
||||||
|
RustBuffer uniffi_zx_document_ffi_fn_func_build_representation_fingerprint(RustBuffer source_fingerprint, RustBuffer renderer_kind, RustBuffer parser_version, uint32_t schema_version, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
||||||
uint32_t uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi(int64_t now_ms, int64_t max_age_ms, RustCallStatus *_Nonnull out_status
|
uint32_t uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi(int64_t now_ms, int64_t max_age_ms, RustCallStatus *_Nonnull out_status
|
||||||
@ -260,7 +265,12 @@ void uniffi_zx_document_ffi_fn_func_clear_exported_events(uint32_t count, RustCa
|
|||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
|
||||||
void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
|
void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_COMPUTE_FILE_FINGERPRINT
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_COMPUTE_FILE_FINGERPRINT
|
||||||
|
RustBuffer uniffi_zx_document_ffi_fn_func_compute_file_fingerprint(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR
|
||||||
@ -292,6 +302,17 @@ RustBuffer uniffi_zx_document_ffi_fn_func_export_pending_events_v2(uint32_t limi
|
|||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_ACTIVE_SESSION_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_ACTIVE_SESSION_V2
|
||||||
|
RustBuffer uniffi_zx_document_ffi_fn_func_get_active_session_v2(RustBuffer material_id, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_EVENT_BUFFER_STATE_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_EVENT_BUFFER_STATE_V2
|
||||||
|
RustBuffer uniffi_zx_document_ffi_fn_func_get_event_buffer_state_v2(RustCallStatus *_Nonnull out_status
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
||||||
@ -299,6 +320,11 @@ RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_p
|
|||||||
RustBuffer uniffi_zx_document_ffi_fn_func_get_office_preview_config_ffi(RustBuffer material_type, uint64_t file_size, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_zx_document_ffi_fn_func_get_office_preview_config_ffi(RustBuffer material_type, uint64_t file_size, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_SESSION_V2_FFI
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_SESSION_V2_FFI
|
||||||
|
RustBuffer uniffi_zx_document_ffi_fn_func_get_session_v2_ffi(RustBuffer session_id, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
|
||||||
int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_type, RustCallStatus *_Nonnull out_status
|
int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_type, RustCallStatus *_Nonnull out_status
|
||||||
@ -309,6 +335,16 @@ int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_typ
|
|||||||
uint32_t uniffi_zx_document_ffi_fn_func_mark_events_failed_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
|
uint32_t uniffi_zx_document_ffi_fn_func_mark_events_failed_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_FAILED_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_FAILED_V2
|
||||||
|
void uniffi_zx_document_ffi_fn_func_mark_session_failed_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_INTERRUPTED_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_INTERRUPTED_V2
|
||||||
|
void uniffi_zx_document_ffi_fn_func_mark_session_interrupted_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_parse_markdown(RustBuffer content, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_zx_document_ffi_fn_func_parse_markdown(RustBuffer content, RustCallStatus *_Nonnull out_status
|
||||||
@ -324,6 +360,16 @@ RustBuffer uniffi_zx_document_ffi_fn_func_parse_text(RustBuffer content, RustCal
|
|||||||
void uniffi_zx_document_ffi_fn_func_pause_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
|
void uniffi_zx_document_ffi_fn_func_pause_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_CHECKPOINT_CHANGED_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_CHECKPOINT_CHANGED_V2
|
||||||
|
RustBuffer uniffi_zx_document_ffi_fn_func_push_checkpoint_changed_v2(RustBuffer session_id, RustBuffer material_id, RustBuffer checkpoint, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
|
||||||
|
RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_checkpoint_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer checkpoint, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer position, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer position, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
||||||
@ -415,6 +461,11 @@ RustBuffer uniffi_zx_document_ffi_fn_func_search_pdf_pages(RustBuffer page_numbe
|
|||||||
RustBuffer uniffi_zx_document_ffi_fn_func_search_text_content(RustBuffer content, RustBuffer query, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_zx_document_ffi_fn_func_search_text_content(RustBuffer content, RustBuffer query, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SET_SESSION_CLOSED_AT_MS_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SET_SESSION_CLOSED_AT_MS_V2
|
||||||
|
void uniffi_zx_document_ffi_fn_func_set_session_closed_at_ms_v2(RustBuffer session_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_start_reading_session_v2(RustBuffer material, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_zx_document_ffi_fn_func_start_reading_session_v2(RustBuffer material, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
||||||
@ -689,6 +740,12 @@ void ffi_zx_document_ffi_rust_future_complete_void(uint64_t handle, RustCallStat
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_ACK_EVENTS_V2
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_ACK_EVENTS_V2
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_ack_events_v2(void
|
uint16_t uniffi_zx_document_ffi_checksum_func_ack_events_v2(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_BUILD_REPRESENTATION_FINGERPRINT
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_BUILD_REPRESENTATION_FINGERPRINT
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_build_representation_fingerprint(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
||||||
@ -707,6 +764,12 @@ uint16_t uniffi_zx_document_ffi_checksum_func_clear_exported_events(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLOSE_READING_SESSION_V2
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLOSE_READING_SESSION_V2
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_close_reading_session_v2(void
|
uint16_t uniffi_zx_document_ffi_checksum_func_close_reading_session_v2(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_COMPUTE_FILE_FINGERPRINT
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_COMPUTE_FILE_FINGERPRINT
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_compute_file_fingerprint(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR
|
||||||
@ -743,12 +806,30 @@ uint16_t uniffi_zx_document_ffi_checksum_func_export_pending_events_v2(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXTRACT_PDF_TEXT_FFI
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXTRACT_PDF_TEXT_FFI
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_extract_pdf_text_ffi(void
|
uint16_t uniffi_zx_document_ffi_checksum_func_extract_pdf_text_ffi(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_ACTIVE_SESSION_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_ACTIVE_SESSION_V2
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_get_active_session_v2(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_EVENT_BUFFER_STATE_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_EVENT_BUFFER_STATE_V2
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_get_event_buffer_state_v2(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_get_office_preview_config_ffi(void
|
uint16_t uniffi_zx_document_ffi_checksum_func_get_office_preview_config_ffi(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_SESSION_V2_FFI
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_SESSION_V2_FFI
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_get_session_v2_ffi(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_IS_OFFICE_TYPE_FFI
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_IS_OFFICE_TYPE_FFI
|
||||||
@ -761,6 +842,18 @@ uint16_t uniffi_zx_document_ffi_checksum_func_is_office_type_ffi(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_EVENTS_FAILED_V2
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_EVENTS_FAILED_V2
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_mark_events_failed_v2(void
|
uint16_t uniffi_zx_document_ffi_checksum_func_mark_events_failed_v2(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_FAILED_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_FAILED_V2
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_mark_session_failed_v2(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_INTERRUPTED_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_INTERRUPTED_V2
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_mark_session_interrupted_v2(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_MARKDOWN
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_MARKDOWN
|
||||||
@ -779,6 +872,18 @@ uint16_t uniffi_zx_document_ffi_checksum_func_parse_text(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PAUSE_READING_SESSION_V2
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PAUSE_READING_SESSION_V2
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_pause_reading_session_v2(void
|
uint16_t uniffi_zx_document_ffi_checksum_func_pause_reading_session_v2(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_CHECKPOINT_CHANGED_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_CHECKPOINT_CHANGED_V2
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_push_checkpoint_changed_v2(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_push_heartbeat_checkpoint_v2(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_V2
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_V2
|
||||||
@ -887,6 +992,12 @@ uint16_t uniffi_zx_document_ffi_checksum_func_search_pdf_pages(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_TEXT_CONTENT
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_TEXT_CONTENT
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_search_text_content(void
|
uint16_t uniffi_zx_document_ffi_checksum_func_search_text_content(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SET_SESSION_CLOSED_AT_MS_V2
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SET_SESSION_CLOSED_AT_MS_V2
|
||||||
|
uint16_t uniffi_zx_document_ffi_checksum_func_set_session_closed_at_ms_v2(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_START_READING_SESSION_V2
|
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_START_READING_SESSION_V2
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
module zx_documentFFI {
|
||||||
|
header "zx_documentFFI.h"
|
||||||
|
export *
|
||||||
|
use "Darwin"
|
||||||
|
use "_Builtin_stdbool"
|
||||||
|
use "_Builtin_stdint"
|
||||||
|
}
|
||||||
@ -8,12 +8,13 @@
|
|||||||
<key>BinaryPath</key>
|
<key>BinaryPath</key>
|
||||||
<string>libzx_document_ffi.a</string>
|
<string>libzx_document_ffi.a</string>
|
||||||
<key>LibraryIdentifier</key>
|
<key>LibraryIdentifier</key>
|
||||||
<string>ios-arm64-simulator</string>
|
<string>ios-arm64_x86_64-simulator</string>
|
||||||
<key>LibraryPath</key>
|
<key>LibraryPath</key>
|
||||||
<string>libzx_document_ffi.a</string>
|
<string>libzx_document_ffi.a</string>
|
||||||
<key>SupportedArchitectures</key>
|
<key>SupportedArchitectures</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
|
<string>x86_64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>SupportedPlatform</key>
|
<key>SupportedPlatform</key>
|
||||||
<string>ios</string>
|
<string>ios</string>
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
module zx_documentFFI { header "zx_documentFFI.h"; export * }
|
|
||||||
@ -1,910 +0,0 @@
|
|||||||
// This file was autogenerated by some hot garbage in the `uniffi` crate.
|
|
||||||
// Trust me, you don't want to mess with it!
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <stddef.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
// The following structs are used to implement the lowest level
|
|
||||||
// of the FFI, and thus useful to multiple uniffied crates.
|
|
||||||
// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H.
|
|
||||||
#ifdef UNIFFI_SHARED_H
|
|
||||||
// We also try to prevent mixing versions of shared uniffi header structs.
|
|
||||||
// If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4
|
|
||||||
#ifndef UNIFFI_SHARED_HEADER_V4
|
|
||||||
#error Combining helper code from multiple versions of uniffi is not supported
|
|
||||||
#endif // ndef UNIFFI_SHARED_HEADER_V4
|
|
||||||
#else
|
|
||||||
#define UNIFFI_SHARED_H
|
|
||||||
#define UNIFFI_SHARED_HEADER_V4
|
|
||||||
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
|
|
||||||
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
|
|
||||||
|
|
||||||
typedef struct RustBuffer
|
|
||||||
{
|
|
||||||
uint64_t capacity;
|
|
||||||
uint64_t len;
|
|
||||||
uint8_t *_Nullable data;
|
|
||||||
} RustBuffer;
|
|
||||||
|
|
||||||
typedef struct ForeignBytes
|
|
||||||
{
|
|
||||||
int32_t len;
|
|
||||||
const uint8_t *_Nullable data;
|
|
||||||
} ForeignBytes;
|
|
||||||
|
|
||||||
// Error definitions
|
|
||||||
typedef struct RustCallStatus {
|
|
||||||
int8_t code;
|
|
||||||
RustBuffer errorBuf;
|
|
||||||
} RustCallStatus;
|
|
||||||
|
|
||||||
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
|
|
||||||
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
|
|
||||||
#endif // def UNIFFI_SHARED_H
|
|
||||||
#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
|
|
||||||
#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
|
|
||||||
typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
|
|
||||||
typedef void (*UniffiForeignFutureDroppedCallback)(uint64_t
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
|
|
||||||
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
|
|
||||||
typedef void (*UniffiCallbackInterfaceFree)(uint64_t
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
|
|
||||||
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
|
|
||||||
typedef uint64_t (*UniffiCallbackInterfaceClone)(uint64_t
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
|
|
||||||
typedef struct UniffiForeignFutureDroppedCallbackStruct {
|
|
||||||
uint64_t handle;
|
|
||||||
UniffiForeignFutureDroppedCallback _Nonnull free;
|
|
||||||
} UniffiForeignFutureDroppedCallbackStruct;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
|
|
||||||
typedef struct UniffiForeignFutureResultU8 {
|
|
||||||
uint8_t returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultU8;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
|
|
||||||
typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureResultU8
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
|
|
||||||
typedef struct UniffiForeignFutureResultI8 {
|
|
||||||
int8_t returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultI8;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
|
|
||||||
typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureResultI8
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
|
|
||||||
typedef struct UniffiForeignFutureResultU16 {
|
|
||||||
uint16_t returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultU16;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
|
|
||||||
typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureResultU16
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
|
|
||||||
typedef struct UniffiForeignFutureResultI16 {
|
|
||||||
int16_t returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultI16;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
|
|
||||||
typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureResultI16
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
|
|
||||||
typedef struct UniffiForeignFutureResultU32 {
|
|
||||||
uint32_t returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultU32;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
|
|
||||||
typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureResultU32
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
|
|
||||||
typedef struct UniffiForeignFutureResultI32 {
|
|
||||||
int32_t returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultI32;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
|
|
||||||
typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureResultI32
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
|
|
||||||
typedef struct UniffiForeignFutureResultU64 {
|
|
||||||
uint64_t returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultU64;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
|
|
||||||
typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureResultU64
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
|
|
||||||
typedef struct UniffiForeignFutureResultI64 {
|
|
||||||
int64_t returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultI64;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
|
|
||||||
typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureResultI64
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
|
|
||||||
typedef struct UniffiForeignFutureResultF32 {
|
|
||||||
float returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultF32;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
|
|
||||||
typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureResultF32
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
|
|
||||||
typedef struct UniffiForeignFutureResultF64 {
|
|
||||||
double returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultF64;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
|
|
||||||
typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureResultF64
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
|
|
||||||
typedef struct UniffiForeignFutureResultRustBuffer {
|
|
||||||
RustBuffer returnValue;
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultRustBuffer;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
|
|
||||||
typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureResultRustBuffer
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
|
|
||||||
typedef struct UniffiForeignFutureResultVoid {
|
|
||||||
RustCallStatus callStatus;
|
|
||||||
} UniffiForeignFutureResultVoid;
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
|
|
||||||
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
|
|
||||||
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
|
|
||||||
);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_ACK_EVENTS_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_ACK_EVENTS_V2
|
|
||||||
uint32_t uniffi_zx_document_ffi_fn_func_ack_events_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
|
||||||
uint32_t uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi(int64_t now_ms, int64_t max_age_ms, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEAR_EXPORTED_EVENTS
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEAR_EXPORTED_EVENTS
|
|
||||||
void uniffi_zx_document_ffi_fn_func_clear_exported_events(uint32_t count, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
|
|
||||||
void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_create_note_anchor(RustBuffer material_id, RustBuffer position, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR_FROM_SEARCH
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR_FROM_SEARCH
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_create_note_anchor_from_search(RustBuffer material_id, RustBuffer result, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_DETECT_MATERIAL_TYPE
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_DETECT_MATERIAL_TYPE
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_detect_material_type(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXPORT_PENDING_EVENTS
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXPORT_PENDING_EVENTS
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_export_pending_events(RustCallStatus *_Nonnull out_status
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXPORT_PENDING_EVENTS_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXPORT_PENDING_EVENTS_V2
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_export_pending_events_v2(uint32_t limit, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_get_office_preview_config_ffi(RustBuffer material_type, uint64_t file_size, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
|
|
||||||
int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_type, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_EVENTS_FAILED_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_EVENTS_FAILED_V2
|
|
||||||
uint32_t uniffi_zx_document_ffi_fn_func_mark_events_failed_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_parse_markdown(RustBuffer content, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_TEXT
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_TEXT
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_parse_text(RustBuffer content, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PAUSE_READING_SESSION_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PAUSE_READING_SESSION_V2
|
|
||||||
void uniffi_zx_document_ffi_fn_func_pause_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer position, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MARKED_AS_READ_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MARKED_AS_READ_V2
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_push_marked_as_read_v2(RustBuffer session_id, RustBuffer material_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MATERIAL_CLOSED_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MATERIAL_CLOSED_V2
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_push_material_closed_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MATERIAL_OPENED_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MATERIAL_OPENED_V2
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_push_material_opened_v2(RustBuffer session_id, RustBuffer material_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_POSITION_CHANGED_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_POSITION_CHANGED_V2
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_push_position_changed_v2(RustBuffer session_id, RustBuffer material_id, RustBuffer position, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_READING_EVENT
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_READING_EVENT
|
|
||||||
void uniffi_zx_document_ffi_fn_func_push_reading_event(RustBuffer event, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_EPUB_CHAPTERS_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_EPUB_CHAPTERS_FFI
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_read_epub_chapters_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_EPUB_METADATA_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_EPUB_METADATA_FFI
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_read_epub_metadata_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_IMAGE_META
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_IMAGE_META
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_read_image_meta(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_PDF_METADATA_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_PDF_METADATA_FFI
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_read_pdf_metadata_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_TEXT_STATS
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_TEXT_STATS
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_read_text_stats(RustBuffer file_path, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RELOAD_STALE_EVENTS_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RELOAD_STALE_EVENTS_V2
|
|
||||||
uint32_t uniffi_zx_document_ffi_fn_func_reload_stale_events_v2(RustCallStatus *_Nonnull out_status
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RESTORE_POSITION_FROM_ANCHOR
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RESTORE_POSITION_FROM_ANCHOR
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_restore_position_from_anchor(RustBuffer anchor, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RESUME_READING_SESSION_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RESUME_READING_SESSION_V2
|
|
||||||
void uniffi_zx_document_ffi_fn_func_resume_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_EPUB_CHAPTERS_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_EPUB_CHAPTERS_FFI
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_search_epub_chapters_ffi(RustBuffer chapter_ids, RustBuffer chapter_texts, RustBuffer query, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_MARKDOWN_BLOCKS
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_MARKDOWN_BLOCKS
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_search_markdown_blocks(RustBuffer blocks, RustBuffer query, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_PDF_PAGES
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_PDF_PAGES
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_search_pdf_pages(RustBuffer page_numbers, RustBuffer page_texts, RustBuffer query, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_TEXT_CONTENT
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_TEXT_CONTENT
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_search_text_content(RustBuffer content, RustBuffer query, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
|
|
||||||
RustBuffer uniffi_zx_document_ffi_fn_func_start_reading_session_v2(RustBuffer material, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_UPDATE_READING_POSITION
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_UPDATE_READING_POSITION
|
|
||||||
void uniffi_zx_document_ffi_fn_func_update_reading_position(RustBuffer material_id, RustBuffer position, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_ALLOC
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_ALLOC
|
|
||||||
RustBuffer ffi_zx_document_ffi_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_FROM_BYTES
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_FROM_BYTES
|
|
||||||
RustBuffer ffi_zx_document_ffi_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_FREE
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_FREE
|
|
||||||
void ffi_zx_document_ffi_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_RESERVE
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_RESERVE
|
|
||||||
RustBuffer ffi_zx_document_ffi_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U8
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U8
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U8
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U8
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_u8(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U8
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U8
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_u8(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U8
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U8
|
|
||||||
uint8_t ffi_zx_document_ffi_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I8
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I8
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I8
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I8
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_i8(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I8
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I8
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_i8(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I8
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I8
|
|
||||||
int8_t ffi_zx_document_ffi_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U16
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U16
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U16
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U16
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_u16(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U16
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U16
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_u16(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U16
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U16
|
|
||||||
uint16_t ffi_zx_document_ffi_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I16
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I16
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I16
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I16
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_i16(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I16
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I16
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_i16(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I16
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I16
|
|
||||||
int16_t ffi_zx_document_ffi_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U32
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U32
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_u32(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U32
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_u32(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U32
|
|
||||||
uint32_t ffi_zx_document_ffi_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I32
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I32
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_i32(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I32
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_i32(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I32
|
|
||||||
int32_t ffi_zx_document_ffi_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U64
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U64
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_u64(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U64
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_u64(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U64
|
|
||||||
uint64_t ffi_zx_document_ffi_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I64
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I64
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_i64(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I64
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_i64(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I64
|
|
||||||
int64_t ffi_zx_document_ffi_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_F32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_F32
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_F32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_F32
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_f32(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_F32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_F32
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_f32(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_F32
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_F32
|
|
||||||
float ffi_zx_document_ffi_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_F64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_F64
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_F64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_F64
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_f64(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_F64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_F64
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_f64(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_F64
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_F64
|
|
||||||
double ffi_zx_document_ffi_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_RUST_BUFFER
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_RUST_BUFFER
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_RUST_BUFFER
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_RUST_BUFFER
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_rust_buffer(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_RUST_BUFFER
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_RUST_BUFFER
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_rust_buffer(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_RUST_BUFFER
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_RUST_BUFFER
|
|
||||||
RustBuffer ffi_zx_document_ffi_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_VOID
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_VOID
|
|
||||||
void ffi_zx_document_ffi_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_VOID
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_VOID
|
|
||||||
void ffi_zx_document_ffi_rust_future_cancel_void(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_VOID
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_VOID
|
|
||||||
void ffi_zx_document_ffi_rust_future_free_void(uint64_t handle
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_VOID
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_VOID
|
|
||||||
void ffi_zx_document_ffi_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_ACK_EVENTS_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_ACK_EVENTS_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_ack_events_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEANUP_STALE_SESSIONS_FFI
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_cleanup_stale_sessions_ffi(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEAR_EXPORTED_EVENTS
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEAR_EXPORTED_EVENTS
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_clear_exported_events(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLOSE_READING_SESSION_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLOSE_READING_SESSION_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_close_reading_session_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_create_note_anchor(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR_FROM_SEARCH
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR_FROM_SEARCH
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_create_note_anchor_from_search(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_DETECT_MATERIAL_TYPE
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_DETECT_MATERIAL_TYPE
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_detect_material_type(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXPORT_PENDING_EVENTS
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXPORT_PENDING_EVENTS
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_export_pending_events(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXPORT_PENDING_EVENTS_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXPORT_PENDING_EVENTS_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_export_pending_events_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXTRACT_PDF_TEXT_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXTRACT_PDF_TEXT_FFI
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_extract_pdf_text_ffi(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_get_office_preview_config_ffi(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_IS_OFFICE_TYPE_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_IS_OFFICE_TYPE_FFI
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_is_office_type_ffi(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_EVENTS_FAILED_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_EVENTS_FAILED_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_mark_events_failed_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_MARKDOWN
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_MARKDOWN
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_parse_markdown(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_TEXT
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_TEXT
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_parse_text(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PAUSE_READING_SESSION_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PAUSE_READING_SESSION_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_pause_reading_session_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_push_heartbeat_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MARKED_AS_READ_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MARKED_AS_READ_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_push_marked_as_read_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MATERIAL_CLOSED_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MATERIAL_CLOSED_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_push_material_closed_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MATERIAL_OPENED_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MATERIAL_OPENED_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_push_material_opened_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_POSITION_CHANGED_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_POSITION_CHANGED_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_push_position_changed_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_READING_EVENT
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_READING_EVENT
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_push_reading_event(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_EPUB_CHAPTERS_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_EPUB_CHAPTERS_FFI
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_read_epub_chapters_ffi(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_EPUB_METADATA_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_EPUB_METADATA_FFI
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_read_epub_metadata_ffi(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_IMAGE_META
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_IMAGE_META
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_read_image_meta(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_PDF_METADATA_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_PDF_METADATA_FFI
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_read_pdf_metadata_ffi(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_TEXT_STATS
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_TEXT_STATS
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_read_text_stats(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RELOAD_STALE_EVENTS_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RELOAD_STALE_EVENTS_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_reload_stale_events_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RESTORE_POSITION_FROM_ANCHOR
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RESTORE_POSITION_FROM_ANCHOR
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_restore_position_from_anchor(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RESUME_READING_SESSION_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RESUME_READING_SESSION_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_resume_reading_session_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_EPUB_CHAPTERS_FFI
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_EPUB_CHAPTERS_FFI
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_search_epub_chapters_ffi(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_MARKDOWN_BLOCKS
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_MARKDOWN_BLOCKS
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_search_markdown_blocks(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_PDF_PAGES
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_PDF_PAGES
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_search_pdf_pages(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_TEXT_CONTENT
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_TEXT_CONTENT
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_search_text_content(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_START_READING_SESSION_V2
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_START_READING_SESSION_V2
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_start_reading_session_v2(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_UPDATE_READING_POSITION
|
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_UPDATE_READING_POSITION
|
|
||||||
uint16_t uniffi_zx_document_ffi_checksum_func_update_reading_position(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_UNIFFI_CONTRACT_VERSION
|
|
||||||
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_UNIFFI_CONTRACT_VERSION
|
|
||||||
uint32_t ffi_zx_document_ffi_uniffi_contract_version(void
|
|
||||||
|
|
||||||
);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -48,7 +48,14 @@ struct AIChatPage: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ForEach(vm.messages) { m in
|
ForEach(vm.messages) { m in
|
||||||
MessageCard(message: m, onRegenerate: { Task { vm.send() } })
|
MessageCard(
|
||||||
|
message: m,
|
||||||
|
canSaveAsKnowledge: m.role == .ai && vm.saveTargetKnowledgeBaseId != nil,
|
||||||
|
onRegenerate: { Task { vm.send() } },
|
||||||
|
onSaveAsKnowledge: {
|
||||||
|
Task { await saveMessageAsKnowledge(m) }
|
||||||
|
}
|
||||||
|
)
|
||||||
.id(m.id)
|
.id(m.id)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
@ -133,7 +140,7 @@ struct AIChatPage: View {
|
|||||||
} label: { Label("删除", systemImage: "trash") }
|
} label: { Label("删除", systemImage: "trash") }
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
try? await RagChatService.shared.updateSession(id: s.id, dto: UpdateChatSessionRequest(isArchived: true))
|
try? await RagChatService.shared.updateSession(id: s.id, dto: UpdateChatSessionRequest(title: nil, isPinned: nil, isArchived: true))
|
||||||
sessions.removeAll { $0.id == s.id }
|
sessions.removeAll { $0.id == s.id }
|
||||||
}
|
}
|
||||||
} label: { Label("归档", systemImage: "archivebox") }
|
} label: { Label("归档", systemImage: "archivebox") }
|
||||||
@ -142,10 +149,13 @@ struct AIChatPage: View {
|
|||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
try? await RagChatService.shared.updateSession(id: s.id, dto: UpdateChatSessionRequest(isPinned: !(s.isPinned ?? false)))
|
try? await RagChatService.shared.updateSession(id: s.id, dto: UpdateChatSessionRequest(title: nil, isPinned: !(s.isPinned ?? false), isArchived: nil))
|
||||||
if let updated = try? await RagChatService.shared.listSessions() { sessions = updated }
|
if let updated = try? await RagChatService.shared.listSessions() { sessions = updated }
|
||||||
}
|
}
|
||||||
} label: { Label(s.isPinned == true ? "取消置顶" : "置顶", systemImage: s.isPinned == true ? "pin.slash" : "pin") }
|
} label: {
|
||||||
|
let pinned = s.isPinned == true
|
||||||
|
Label(pinned ? "取消置顶" : "置顶", systemImage: pinned ? "pin.slash" : "pin")
|
||||||
|
}
|
||||||
.tint(Color.zxPurple)
|
.tint(Color.zxPurple)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -175,6 +185,43 @@ struct AIChatPage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func saveMessageAsKnowledge(_ message: AIMessage) async {
|
||||||
|
guard let knowledgeBaseId = vm.saveTargetKnowledgeBaseId else {
|
||||||
|
ZXToastManager.shared.error("当前范围不支持保存为知识点")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let currentSessionId = vm.currentSessionId else {
|
||||||
|
ZXToastManager.shared.error("当前对话尚未就绪")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !content.isEmpty else {
|
||||||
|
ZXToastManager.shared.error("内容为空,无法保存")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let scopeName = vm.saveTargetScopeName ?? "AI 对话"
|
||||||
|
let titleSeed = content
|
||||||
|
.components(separatedBy: .newlines)
|
||||||
|
.first?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "AI 对话知识点"
|
||||||
|
let title = String(titleSeed.prefix(40)).isEmpty ? "AI 对话知识点" : String(titleSeed.prefix(40))
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await RagChatService.shared.saveMessageAsKnowledge(
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
messageId: message.id,
|
||||||
|
knowledgeBaseId: knowledgeBaseId,
|
||||||
|
title: title,
|
||||||
|
summary: nil
|
||||||
|
)
|
||||||
|
ZXToastManager.shared.success(result.message)
|
||||||
|
} catch {
|
||||||
|
ZXToastManager.shared.error("保存失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func scopeIcon(for type: String?) -> String {
|
private func scopeIcon(for type: String?) -> String {
|
||||||
switch type {
|
switch type {
|
||||||
case "knowledge_base": return "books.vertical"
|
case "knowledge_base": return "books.vertical"
|
||||||
@ -199,7 +246,9 @@ struct AIChatPage: View {
|
|||||||
|
|
||||||
struct MessageCard: View {
|
struct MessageCard: View {
|
||||||
let message: AIMessage
|
let message: AIMessage
|
||||||
|
let canSaveAsKnowledge: Bool
|
||||||
let onRegenerate: () -> Void
|
let onRegenerate: () -> Void
|
||||||
|
let onSaveAsKnowledge: () -> Void
|
||||||
@State private var thinkingExpanded = true
|
@State private var thinkingExpanded = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -232,6 +281,11 @@ struct MessageCard: View {
|
|||||||
Button { UIPasteboard.general.string = message.content } label: {
|
Button { UIPasteboard.general.string = message.content } label: {
|
||||||
Label("复制", systemImage: "doc.on.doc").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
Label("复制", systemImage: "doc.on.doc").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
}
|
}
|
||||||
|
if canSaveAsKnowledge {
|
||||||
|
Button { onSaveAsKnowledge() } label: {
|
||||||
|
Label("存为知识点", systemImage: "square.and.arrow.down").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
Button { onRegenerate() } label: {
|
Button { onRegenerate() } label: {
|
||||||
Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,14 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AIMessage: Identifiable {
|
struct AIMessage: Identifiable {
|
||||||
let id: UUID
|
let id: String
|
||||||
let role: AIMessageRole
|
let role: AIMessageRole
|
||||||
let content: String
|
let content: String
|
||||||
let thinkingContent: String?
|
let thinkingContent: String?
|
||||||
let citations: [ChatCitation]?
|
let citations: [ChatCitation]?
|
||||||
let isStreaming: Bool
|
let isStreaming: Bool
|
||||||
|
|
||||||
init(role: AIMessageRole, content: String, thinkingContent: String? = nil, citations: [ChatCitation]? = nil, isStreaming: Bool = false, id: UUID = UUID()) {
|
init(role: AIMessageRole, content: String, thinkingContent: String? = nil, citations: [ChatCitation]? = nil, isStreaming: Bool = false, id: String = UUID().uuidString) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.role = role
|
self.role = role
|
||||||
self.content = content
|
self.content = content
|
||||||
@ -61,6 +61,24 @@ final class AIChatViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var saveTargetKnowledgeBaseId: String? {
|
||||||
|
if let kbId = entryContext?.parentKnowledgeBaseId, !kbId.isEmpty {
|
||||||
|
return kbId
|
||||||
|
}
|
||||||
|
if entryContext?.scopeType == .knowledgeBase {
|
||||||
|
return entryContext?.scopeId
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var saveTargetScopeName: String? {
|
||||||
|
entryContext?.scopeName
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSessionId: String? {
|
||||||
|
sessionId
|
||||||
|
}
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
guard sessionId == nil else { return }
|
guard sessionId == nil else { return }
|
||||||
|
|
||||||
@ -127,7 +145,7 @@ final class AIChatViewModel: ObservableObject {
|
|||||||
inputText = ""
|
inputText = ""
|
||||||
isSending = true
|
isSending = true
|
||||||
|
|
||||||
let placeholderId = UUID()
|
let placeholderId = UUID().uuidString
|
||||||
messages.append(AIMessage(role: .ai, content: "", isStreaming: true, id: placeholderId))
|
messages.append(AIMessage(role: .ai, content: "", isStreaming: true, id: placeholderId))
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
@ -167,6 +185,7 @@ final class AIChatViewModel: ObservableObject {
|
|||||||
messages[idx] = AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)")
|
messages[idx] = AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await loadSession(sid)
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -203,7 +222,12 @@ final class AIChatViewModel: ObservableObject {
|
|||||||
messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于\(scopePrefix)内容回答问题。")]
|
messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于\(scopePrefix)内容回答问题。")]
|
||||||
} else {
|
} else {
|
||||||
messages = msgs.map { m in
|
messages = msgs.map { m in
|
||||||
AIMessage(role: m.role == "user" ? .user : .ai, content: m.content, citations: m.citations)
|
AIMessage(
|
||||||
|
role: m.role == "user" ? .user : .ai,
|
||||||
|
content: m.content,
|
||||||
|
citations: m.citations,
|
||||||
|
id: m.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -64,39 +64,24 @@ class ActivityViewModel: ObservableObject {
|
|||||||
self.activityService = activityService
|
self.activityService = activityService
|
||||||
self.focusService = focusService
|
self.focusService = focusService
|
||||||
self.readingService = readingService
|
self.readingService = readingService
|
||||||
|
// 初始化时立即加载,不等待 onAppear
|
||||||
|
Task { await loadAll() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAll() async {
|
func loadAll() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
async let s = try? activityService.summary()
|
Task { summary = try? await activityService.summary() }
|
||||||
async let f = try? focusService.listAll()
|
Task { focusItems = (try? await focusService.listAll()) ?? [] }
|
||||||
async let h = try? activityService.heatmap()
|
Task { heatmap = (try? await activityService.heatmap()) ?? [:] }
|
||||||
async let st = try? activityService.streak()
|
Task { streak = try? await activityService.streak() }
|
||||||
async let t = try? activityService.trend(days: 7)
|
Task { trends = (try? await activityService.trend(days: 7)) ?? [] }
|
||||||
async let r = try? activityService.recommendations()
|
Task { recommendations = (try? await activityService.recommendations()) ?? [] }
|
||||||
async let cr = try? readingService.getContinueLearning()
|
Task { continueReading = try? await readingService.getContinueLearning() }
|
||||||
async let ls = try? readingService.getLearningSummary()
|
Task { learningSummary = try? await readingService.getLearningSummary() }
|
||||||
async let ltr = try? readingService.getLearningTrend(days: 7)
|
Task { readingTrend = try? await readingService.getLearningTrend(days: 7) }
|
||||||
async let recs = try? readingService.getLearningRecords(limit: 10, type: "reading")
|
Task { recentRecords = (try? await readingService.getLearningRecords(limit: 10, type: "reading"))?.items ?? [] }
|
||||||
|
|
||||||
let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs)
|
|
||||||
|
|
||||||
summary = summaryResult
|
|
||||||
learningSummary = lsResult
|
|
||||||
readingTrend = ltrResult
|
|
||||||
focusItems = focusResult ?? []
|
|
||||||
heatmap = heatmapResult ?? [:]
|
|
||||||
streak = streakResult
|
|
||||||
trends = trendResult ?? []
|
|
||||||
recommendations = recResult ?? []
|
|
||||||
continueReading = continueResult
|
|
||||||
recentRecords = recordsResult?.items ?? []
|
|
||||||
|
|
||||||
if summary == nil {
|
|
||||||
errorMessage = "加载分析数据失败,请下拉刷新重试"
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
@ -104,27 +89,15 @@ class ActivityViewModel: ObservableObject {
|
|||||||
func refresh() async {
|
func refresh() async {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
async let s = try? activityService.summary()
|
Task { summary = try? await activityService.summary() }
|
||||||
async let f = try? focusService.listAll()
|
Task { focusItems = (try? await focusService.listAll()) ?? [] }
|
||||||
async let h = try? activityService.heatmap()
|
Task { heatmap = (try? await activityService.heatmap()) ?? [:] }
|
||||||
async let st = try? activityService.streak()
|
Task { streak = try? await activityService.streak() }
|
||||||
async let t = try? activityService.trend(days: 7)
|
Task { trends = (try? await activityService.trend(days: 7)) ?? [] }
|
||||||
async let r = try? activityService.recommendations()
|
Task { recommendations = (try? await activityService.recommendations()) ?? [] }
|
||||||
async let cr = try? readingService.getContinueLearning()
|
Task { continueReading = try? await readingService.getContinueLearning() }
|
||||||
async let ls = try? readingService.getLearningSummary()
|
Task { learningSummary = try? await readingService.getLearningSummary() }
|
||||||
async let ltr = try? readingService.getLearningTrend(days: 7)
|
Task { readingTrend = try? await readingService.getLearningTrend(days: 7) }
|
||||||
async let recs = try? readingService.getLearningRecords(limit: 10, type: "reading")
|
Task { recentRecords = (try? await readingService.getLearningRecords(limit: 10, type: "reading"))?.items ?? [] }
|
||||||
|
|
||||||
let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs)
|
|
||||||
|
|
||||||
summary = summaryResult
|
|
||||||
learningSummary = lsResult
|
|
||||||
readingTrend = ltrResult
|
|
||||||
focusItems = focusResult ?? []
|
|
||||||
heatmap = heatmapResult ?? [:]
|
|
||||||
streak = streakResult
|
|
||||||
trends = trendResult ?? []
|
|
||||||
recommendations = recResult ?? []
|
|
||||||
continueReading = continueResult
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct AnalysisHomeView: View {
|
struct AnalysisHomeView: View {
|
||||||
@StateObject private var viewModel = ActivityViewModel()
|
@StateObject private var viewModel = ActivityViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.zxCanvas.ignoresSafeArea()
|
Color.zxCanvas.ignoresSafeArea()
|
||||||
@ -166,7 +167,7 @@ struct AnalysisHomeView: View {
|
|||||||
.refreshable { await viewModel.refresh() }
|
.refreshable { await viewModel.refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await viewModel.loadAll() }
|
.refreshable { await viewModel.refresh() }
|
||||||
.navigationDestination(for: Route.self) { $0.destination }
|
.navigationDestination(for: Route.self) { $0.destination }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -96,7 +96,7 @@ struct FolderManageView: View {
|
|||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
folders = (try? await KnowledgeFolderService.shared.list(kbId: knowledgeBaseId)) ?? []
|
folders = (try? await KnowledgeFolderService.shared.list(knowledgeBaseId: knowledgeBaseId)) ?? []
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,26 +139,46 @@ struct LibraryDetailPage: View {
|
|||||||
@State private var showManageView = false
|
@State private var showManageView = false
|
||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
@State private var isDeleting = false
|
@State private var isDeleting = false
|
||||||
@State private var isSelectMode = false
|
// 批量选择 — 每个 Tab 独立
|
||||||
@State private var selectedIds: Set<String> = []
|
@State private var sourceSelectMode = false
|
||||||
@State private var showBatchDeleteConfirm = false
|
@State private var selectedSourceIds: Set<String> = []
|
||||||
@State private var detailTab = 1
|
@State private var showSourceDeleteConfirm = false
|
||||||
|
@State private var sourceToDelete: KnowledgeSource?
|
||||||
|
@State private var showSourceBatchDelete = false
|
||||||
|
@State private var showSourceBatchGenerate = false
|
||||||
|
@State private var isBatchGeneratingSources = false
|
||||||
|
|
||||||
|
@State private var itemSelectMode = false
|
||||||
|
@State private var selectedItemIds: Set<String> = []
|
||||||
|
@State private var showItemBatchDelete = false
|
||||||
|
|
||||||
|
@State private var detailTab = 0 // 0=资料来源, 1=知识点
|
||||||
|
|
||||||
private func appear() async {
|
private func appear() async {
|
||||||
print("[LibraryDetail] appear() START, kbId=\(knowledgeBaseId)")
|
|
||||||
viewModel.knowledgeBase = try? await KnowledgeBaseService.shared.detail(id: knowledgeBaseId)
|
viewModel.knowledgeBase = try? await KnowledgeBaseService.shared.detail(id: knowledgeBaseId)
|
||||||
print("[LibraryDetail] KB loaded: \(viewModel.knowledgeBase?.title ?? "nil")")
|
|
||||||
await loadSources()
|
await loadSources()
|
||||||
print("[LibraryDetail] appear() DONE")
|
|
||||||
}
|
}
|
||||||
@State private var sortOption = 0
|
@State private var sortOption = 1
|
||||||
@State private var showFolderManager = false
|
@State private var showFolderManager = false
|
||||||
@State private var sources: [KnowledgeSource] = []
|
@State private var sources: [KnowledgeSource] = []
|
||||||
@State private var sourceReadingStatus: [String: String] = [:]
|
@State private var sourceReadingStatus: [String: String] = [:]
|
||||||
|
@State private var sourceTaskStatusMap: [String: KnowledgeGenerationTaskStatus] = [:]
|
||||||
@State private var isLoadingSources = true
|
@State private var isLoadingSources = true
|
||||||
|
@State private var sourceTaskPollingTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var allSelected: Bool {
|
private var sourceAllSelected: Bool {
|
||||||
!viewModel.items.isEmpty && selectedIds.count == viewModel.items.count
|
!sources.isEmpty && selectedSourceIds.count == sources.count
|
||||||
|
}
|
||||||
|
private var itemAllSelected: Bool {
|
||||||
|
!viewModel.items.isEmpty && selectedItemIds.count == viewModel.items.count
|
||||||
|
}
|
||||||
|
// 当前 Tab 是否在选择模式
|
||||||
|
private var isInSelectMode: Bool {
|
||||||
|
detailTab == 0 ? sourceSelectMode : itemSelectMode
|
||||||
|
}
|
||||||
|
// 当前选中的数量
|
||||||
|
private var currentSelectedCount: Int {
|
||||||
|
detailTab == 0 ? selectedSourceIds.count : selectedItemIds.count
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -185,126 +205,20 @@ struct LibraryDetailPage: View {
|
|||||||
.padding(.horizontal, 20).padding(.top, 8)
|
.padding(.horizontal, 20).padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab 切换
|
||||||
|
Picker("", selection: $detailTab) {
|
||||||
|
Text("资料来源").tag(0)
|
||||||
|
Text("知识点").tag(1)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, 20).padding(.vertical, 8)
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if detailTab == 0 {
|
if detailTab == 1 {
|
||||||
if viewModel.isLoading && viewModel.items.isEmpty {
|
itemTabContent()
|
||||||
VStack(spacing: 12) {
|
|
||||||
ProgressView()
|
|
||||||
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity).padding(.top, 80)
|
|
||||||
}
|
|
||||||
ForEach(viewModel.items) { item in
|
|
||||||
let icon = fileTypeIcon(for: item)
|
|
||||||
let type = fileTypeText(for: item)
|
|
||||||
let date = formatShortDate(item.updatedAt)
|
|
||||||
let progress = progressFor(item)
|
|
||||||
if isSelectMode {
|
|
||||||
Button {
|
|
||||||
if selectedIds.contains(item.id) { selectedIds.remove(item.id) }
|
|
||||||
else { selectedIds.insert(item.id) }
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: selectedIds.contains(item.id) ? "checkmark.circle" : "circle")
|
|
||||||
.font(.system(size: 20))
|
|
||||||
.foregroundColor(selectedIds.contains(item.id) ? Color.zxPrimary : Color.zxF03)
|
|
||||||
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
} else {
|
} else {
|
||||||
NavigationLink(value: Route.knowledgeDetail(item: item)) {
|
sourceTabContent()
|
||||||
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
Button {
|
|
||||||
isSelectMode = true
|
|
||||||
selectedIds.insert(item.id)
|
|
||||||
} label: {
|
|
||||||
Label("多选", systemImage: "checkmark.circle")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
// TODO: rename
|
|
||||||
} label: {
|
|
||||||
Label("重命名", image: "icon-pencil")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
// TODO: move to folder
|
|
||||||
} label: {
|
|
||||||
Label("移动到", image: "icon-folder")
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.batchDeleteItems(ids: [item.id])
|
|
||||||
await viewModel.refresh(knowledgeBaseId: knowledgeBaseId)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("删除", image: "icon-trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if viewModel.items.isEmpty && !viewModel.isLoading {
|
|
||||||
Text("暂无知识点").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
|
|
||||||
}
|
|
||||||
if viewModel.hasMore {
|
|
||||||
ZXLoadMoreFooter { await viewModel.loadMore(knowledgeBaseId: knowledgeBaseId) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if isLoadingSources {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
ProgressView().tint(Color.zxPurple)
|
|
||||||
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
|
||||||
}.padding(.top, 80)
|
|
||||||
} else if sources.isEmpty {
|
|
||||||
Text("暂无资料来源").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
|
|
||||||
} else {
|
|
||||||
ForEach(sources) { src in
|
|
||||||
NavigationLink(value: Route.materialReader(
|
|
||||||
materialId: src.id,
|
|
||||||
filePath: "",
|
|
||||||
materialType: materialTypeFromMime(src.mimeType, filename: src.originalFilename),
|
|
||||||
knowledgeBaseId: knowledgeBaseId,
|
|
||||||
title: src.originalFilename ?? src.title ?? "未命名"
|
|
||||||
)) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: src.type == "file" ? "doc" : "link")
|
|
||||||
.font(.system(size: 18)).foregroundColor(Color.zxPurple)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(src.originalFilename ?? src.title ?? "未命名")
|
|
||||||
.font(.system(size: 15, weight: .medium)).foregroundColor(Color.zxF0).lineLimit(1)
|
|
||||||
if let len = src.textLength, len > 0 {
|
|
||||||
Text("\(len) 字")
|
|
||||||
.font(.system(size: 13)).foregroundColor(Color.zxF04)
|
|
||||||
}
|
|
||||||
// Reading status badge
|
|
||||||
if let status = sourceReadingStatus[src.id], status != "not_started" {
|
|
||||||
Text(status == "read" ? "已读" : "阅读中")
|
|
||||||
.font(.system(size: 10, weight: .medium))
|
|
||||||
.foregroundColor(status == "read" ? Color.green : Color.zxPurple)
|
|
||||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
||||||
.background((status == "read" ? Color.green : Color.zxPurple).opacity(0.1))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button {
|
|
||||||
Task { await deleteSource(src) }
|
|
||||||
} label: {
|
|
||||||
Image("icon-trash").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
Color.zxHairline.frame(height: 0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.bottom, 80)
|
.padding(.horizontal, 20).padding(.bottom, 80)
|
||||||
@ -315,33 +229,59 @@ struct LibraryDetailPage: View {
|
|||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
|
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
|
||||||
.onChange(of: detailTab) { _, newTab in
|
.onChange(of: detailTab) { _, newTab in
|
||||||
if newTab == 1 && sources.isEmpty { Task { await loadSources() } }
|
// 切换 Tab 时退出选择模式
|
||||||
|
sourceSelectMode = false; selectedSourceIds.removeAll()
|
||||||
|
itemSelectMode = false; selectedItemIds.removeAll()
|
||||||
|
if newTab == 0 && sources.isEmpty { Task { await loadSources() } }
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if isSelectMode {
|
if isInSelectMode {
|
||||||
|
// ── 选择模式 ──
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button("取消") { isSelectMode = false; selectedIds.removeAll() }
|
Button("取消") {
|
||||||
}
|
if detailTab == 0 { sourceSelectMode = false; selectedSourceIds.removeAll() }
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
else { itemSelectMode = false; selectedItemIds.removeAll() }
|
||||||
Button { selectedIds = allSelected ? [] : Set(viewModel.items.map(\.id)) } label: {
|
|
||||||
Text(allSelected ? "取消全选" : "全选").font(.system(size: 14))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showBatchDeleteConfirm = true
|
if detailTab == 0 {
|
||||||
} label: {
|
selectedSourceIds = sourceAllSelected ? [] : Set(sources.map(\.id))
|
||||||
Image("icon-trash").resizable().scaledToFit().frame(width: 18, height: 18).foregroundColor(selectedIds.isEmpty ? Color.zxF03 : Color.zxCoral)
|
} else {
|
||||||
|
selectedItemIds = itemAllSelected ? [] : Set(viewModel.items.map(\.id))
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
let all = detailTab == 0 ? sourceAllSelected : itemAllSelected
|
||||||
|
Text(all ? "取消全选(\(currentSelectedCount))" : "全选(\(currentSelectedCount))").font(.system(size: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
if detailTab == 0 { showSourceBatchGenerate = true }
|
||||||
|
else { showItemBatchDelete = true }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: detailTab == 0 ? "brain.head.profile" : "trash")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(currentSelectedCount == 0 ? Color.zxF03 : (detailTab == 0 ? Color.zxPurple : Color.zxCoral))
|
||||||
|
}
|
||||||
|
.disabled(currentSelectedCount == 0)
|
||||||
|
}
|
||||||
|
if detailTab == 0 {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showSourceBatchDelete = true
|
||||||
|
} label: {
|
||||||
|
Image("icon-trash").resizable().scaledToFit().frame(width: 18, height: 18)
|
||||||
|
.foregroundColor(currentSelectedCount == 0 ? Color.zxF03 : Color.zxCoral)
|
||||||
|
}
|
||||||
|
.disabled(currentSelectedCount == 0)
|
||||||
}
|
}
|
||||||
.disabled(selectedIds.isEmpty)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// ── 普通模式 ──
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Menu {
|
Menu {
|
||||||
Button { sortOption = 0 } label: {
|
|
||||||
Label("默认排序", systemImage: sortOption == 0 ? "checkmark" : "")
|
|
||||||
}
|
|
||||||
Button { sortOption = 1 } label: {
|
Button { sortOption = 1 } label: {
|
||||||
Label("文件名排序", systemImage: sortOption == 1 ? "checkmark" : "")
|
Label("文件名排序", systemImage: sortOption == 1 ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
@ -372,8 +312,17 @@ struct LibraryDetailPage: View {
|
|||||||
NavigationLink(value: Route.quizList(knowledgeBaseId: knowledgeBaseId)) {
|
NavigationLink(value: Route.quizList(knowledgeBaseId: knowledgeBaseId)) {
|
||||||
Label("答题测验", image: "icon-pencil")
|
Label("答题测验", image: "icon-pencil")
|
||||||
}
|
}
|
||||||
|
if detailTab == 0 {
|
||||||
Button {
|
Button {
|
||||||
isSelectMode = true
|
sourceSelectMode = true
|
||||||
|
selectedSourceIds.removeAll()
|
||||||
|
} label: {
|
||||||
|
Label("批量生成知识点", systemImage: "brain.head.profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
if detailTab == 0 { sourceSelectMode = true }
|
||||||
|
else { itemSelectMode = true }
|
||||||
} label: {
|
} label: {
|
||||||
Label("批量选择", systemImage: "checkmark.circle")
|
Label("批量选择", systemImage: "checkmark.circle")
|
||||||
}
|
}
|
||||||
@ -407,19 +356,49 @@ struct LibraryDetailPage: View {
|
|||||||
.sheet(isPresented: $showManageView) {
|
.sheet(isPresented: $showManageView) {
|
||||||
KnowledgeBaseManageView(knowledgeBaseId: knowledgeBaseId)
|
KnowledgeBaseManageView(knowledgeBaseId: knowledgeBaseId)
|
||||||
}
|
}
|
||||||
.alert("批量删除", isPresented: $showBatchDeleteConfirm) {
|
.alert("删除资料来源", isPresented: $showSourceDeleteConfirm) {
|
||||||
|
Button("取消", role: .cancel) { sourceToDelete = nil }
|
||||||
|
Button("删除", role: .destructive) {
|
||||||
|
if let src = sourceToDelete { Task { await deleteSource(src) } }
|
||||||
|
sourceToDelete = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("确定要删除「\(sourceToDelete?.originalFilename ?? sourceToDelete?.title ?? "")」吗?关联文件也将被删除。")
|
||||||
|
}
|
||||||
|
.alert("批量删除资料来源", isPresented: $showSourceBatchDelete) {
|
||||||
Button("取消", role: .cancel) {}
|
Button("取消", role: .cancel) {}
|
||||||
Button("删除 \(selectedIds.count) 个", role: .destructive) {
|
Button("删除 \(selectedSourceIds.count) 个", role: .destructive) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.batchDeleteItems(ids: Array(selectedIds))
|
for id in selectedSourceIds { await deleteSource(byId: id) }
|
||||||
isSelectMode = false
|
selectedSourceIds.removeAll(); sourceSelectMode = false
|
||||||
selectedIds.removeAll()
|
await loadSources()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text("确定要删除选中的 \(selectedIds.count) 个知识点吗?此操作不可恢复。")
|
Text("确定要删除选中的 \(selectedSourceIds.count) 个资料来源吗?关联文件也将被删除。此操作不可恢复。")
|
||||||
|
}
|
||||||
|
.alert("批量生成知识点", isPresented: $showSourceBatchGenerate) {
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
Button(isBatchGeneratingSources ? "提交中..." : "生成 \(selectedSourceIds.count) 个") {
|
||||||
|
Task { await generateKnowledgeForSelectedSources() }
|
||||||
|
}
|
||||||
|
.disabled(selectedSourceIds.isEmpty || isBatchGeneratingSources)
|
||||||
|
} message: {
|
||||||
|
Text("选中的资料会提交到异步队列。已解析的资料会立即入队,未解析完成的资料会先进入等待状态。")
|
||||||
|
}
|
||||||
|
.alert("批量删除知识点", isPresented: $showItemBatchDelete) {
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
Button("删除 \(selectedItemIds.count) 个", role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.batchDeleteItems(ids: Array(selectedItemIds))
|
||||||
|
selectedItemIds.removeAll(); itemSelectMode = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("确定要删除选中的 \(selectedItemIds.count) 个知识点吗?此操作不可恢复。")
|
||||||
}
|
}
|
||||||
.task { await appear() }
|
.task { await appear() }
|
||||||
|
.onDisappear { stopSourceTaskPolling() }
|
||||||
.sheet(isPresented: $showFolderManager) {
|
.sheet(isPresented: $showFolderManager) {
|
||||||
FolderManageView(knowledgeBaseId: knowledgeBaseId)
|
FolderManageView(knowledgeBaseId: knowledgeBaseId)
|
||||||
}
|
}
|
||||||
@ -611,6 +590,26 @@ struct LibraryDetailPage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func formatFileSize(_ bytes: Int) -> String {
|
||||||
|
if bytes >= 1_048_576 { return String(format: "%.1f MB", Double(bytes) / 1_048_576) }
|
||||||
|
if bytes >= 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
|
||||||
|
return "\(bytes) B"
|
||||||
|
}
|
||||||
|
private func formatSourceDate(_ iso: String?) -> String? {
|
||||||
|
guard let iso else { return nil }
|
||||||
|
let df = ISO8601DateFormatter()
|
||||||
|
df.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let date = df.date(from: iso) ?? {
|
||||||
|
df.formatOptions = [.withInternetDateTime]
|
||||||
|
return df.date(from: iso)
|
||||||
|
}()
|
||||||
|
guard let date else { return nil }
|
||||||
|
let cal = Calendar.current
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateFormat = cal.isDate(date, equalTo: Date(), toGranularity: .year) ? "M/d" : "yyyy/M/d"
|
||||||
|
return fmt.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
private func formatShortDate(_ iso: String?) -> String {
|
private func formatShortDate(_ iso: String?) -> String {
|
||||||
guard let iso else { return "" }
|
guard let iso else { return "" }
|
||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
@ -650,27 +649,327 @@ struct LibraryDetailPage: View {
|
|||||||
|
|
||||||
private func loadSources(sortBy: String? = nil, order: String? = nil) async {
|
private func loadSources(sortBy: String? = nil, order: String? = nil) async {
|
||||||
isLoadingSources = true
|
isLoadingSources = true
|
||||||
print("[LibraryDetail] loadSources START, kbId=\(knowledgeBaseId)")
|
|
||||||
do {
|
do {
|
||||||
sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId, sortBy: sortBy, order: order)
|
sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId, sortBy: sortBy, order: order)
|
||||||
print("[LibraryDetail] loadSources OK, count=\(sources.count)")
|
await refreshSourceTaskStatuses()
|
||||||
for src in sources {
|
|
||||||
print("[LibraryDetail] source: id=\(src.id), title=\(src.title ?? "-"), parseStatus=\(src.parseStatus ?? "-")")
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
print("[LibraryDetail] loadSources ERROR: \(error.localizedDescription)")
|
print("[LibraryDetail] loadSources ERROR: \(error.localizedDescription)")
|
||||||
print("[LibraryDetail] Full error: \(error)")
|
|
||||||
}
|
}
|
||||||
isLoadingSources = false
|
isLoadingSources = false
|
||||||
|
restartSourceTaskPollingIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteSource(_ src: KnowledgeSource) async {
|
private func deleteSource(_ src: KnowledgeSource) async {
|
||||||
|
await deleteSource(byId: src.id)
|
||||||
|
}
|
||||||
|
private func deleteSource(byId id: String) async {
|
||||||
do {
|
do {
|
||||||
let _ = try await KnowledgeSourceService.shared.delete(kbId: knowledgeBaseId, id: src.id)
|
let _ = try await KnowledgeSourceService.shared.delete(kbId: knowledgeBaseId, id: id)
|
||||||
sources.removeAll { $0.id == src.id }
|
sources.removeAll { $0.id == id }
|
||||||
ZXToastManager.shared.success("已删除")
|
ZXToastManager.shared.success("已删除")
|
||||||
} catch { ZXToastManager.shared.error("删除失败") }
|
} catch { ZXToastManager.shared.error("删除失败") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func generateKnowledgeForSelectedSources() async {
|
||||||
|
guard !selectedSourceIds.isEmpty, !isBatchGeneratingSources else { return }
|
||||||
|
isBatchGeneratingSources = true
|
||||||
|
defer { isBatchGeneratingSources = false }
|
||||||
|
do {
|
||||||
|
let result = try await KnowledgeSourceService.shared.generateKnowledgeBatch(
|
||||||
|
kbId: knowledgeBaseId,
|
||||||
|
sourceIds: Array(selectedSourceIds)
|
||||||
|
)
|
||||||
|
let queued = result.queuedCount
|
||||||
|
let waiting = result.waitingForParseCount
|
||||||
|
let message = waiting > 0
|
||||||
|
? "已提交 \(result.totalCount) 个任务,\(queued) 个已入队,\(waiting) 个等待解析"
|
||||||
|
: "已提交 \(result.totalCount) 个知识点生成任务"
|
||||||
|
ZXToastManager.shared.success(message)
|
||||||
|
selectedSourceIds.removeAll()
|
||||||
|
sourceSelectMode = false
|
||||||
|
await loadSources()
|
||||||
|
restartSourceTaskPollingIfNeeded()
|
||||||
|
} catch {
|
||||||
|
ZXToastManager.shared.error("批量生成失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sourceStatusBadge(for source: KnowledgeSource) -> (text: String, color: Color)? {
|
||||||
|
let effectiveStatus = sourceTaskStatusMap[source.id]?.status ?? source.learningStatus
|
||||||
|
switch effectiveStatus {
|
||||||
|
case "waiting_for_parse":
|
||||||
|
return ("等待解析", Color.orange)
|
||||||
|
case "queued":
|
||||||
|
return ("已入队", Color.zxPurple)
|
||||||
|
case "running":
|
||||||
|
return ("生成中", Color.blue)
|
||||||
|
case "completed":
|
||||||
|
return ("已生成", Color.green)
|
||||||
|
case "failed":
|
||||||
|
return ("生成失败", Color.zxCoral)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
switch source.parseStatus {
|
||||||
|
case "pending", "processing", "queued":
|
||||||
|
return ("解析中", Color.orange)
|
||||||
|
case "failed":
|
||||||
|
return ("解析失败", Color.zxCoral)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBadge(text: String, color: Color) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(color)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(color.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readingStatusBadge(_ status: String) -> some View {
|
||||||
|
let isRead = status == "read"
|
||||||
|
let color = isRead ? Color.green : Color.zxPurple
|
||||||
|
let label = isRead ? "已读" : "阅读中"
|
||||||
|
|
||||||
|
return Text(label)
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(color)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(color.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshSourceTaskStatuses() async {
|
||||||
|
do {
|
||||||
|
let response = try await KnowledgeSourceService.shared.knowledgeGenerationTasks(
|
||||||
|
kbId: knowledgeBaseId,
|
||||||
|
sourceIds: sources.map(\.id)
|
||||||
|
)
|
||||||
|
sourceTaskStatusMap = Dictionary(
|
||||||
|
uniqueKeysWithValues: response.items.compactMap { item in
|
||||||
|
guard let sourceId = item.sourceId else { return nil }
|
||||||
|
return (sourceId, item)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
print("[LibraryDetail] refreshSourceTaskStatuses ERROR: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartSourceTaskPollingIfNeeded() {
|
||||||
|
stopSourceTaskPolling()
|
||||||
|
let hasActiveTask = sourceTaskStatusMap.values.contains { isKnowledgeTaskActive($0.status) }
|
||||||
|
guard hasActiveTask else { return }
|
||||||
|
|
||||||
|
sourceTaskPollingTask = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await refreshSourceTaskStatuses()
|
||||||
|
let stillActive = sourceTaskStatusMap.values.contains { isKnowledgeTaskActive($0.status) }
|
||||||
|
if !stillActive { break }
|
||||||
|
do {
|
||||||
|
sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId)
|
||||||
|
} catch {
|
||||||
|
print("[LibraryDetail] polling source refresh ERROR: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceTaskPollingTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopSourceTaskPolling() {
|
||||||
|
sourceTaskPollingTask?.cancel()
|
||||||
|
sourceTaskPollingTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isKnowledgeTaskActive(_ status: String) -> Bool {
|
||||||
|
["waiting_for_parse", "queued", "running"].contains(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func itemTabContent() -> some View {
|
||||||
|
if viewModel.isLoading && viewModel.items.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(viewModel.items) { item in
|
||||||
|
itemRowView(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.items.isEmpty && !viewModel.isLoading {
|
||||||
|
Text("暂无知识点")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(Color.zxF03)
|
||||||
|
.padding(.top, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.hasMore {
|
||||||
|
ZXLoadMoreFooter { await viewModel.loadMore(knowledgeBaseId: knowledgeBaseId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func itemRowView(_ item: KnowledgeItem) -> some View {
|
||||||
|
let icon = fileTypeIcon(for: item)
|
||||||
|
let type = fileTypeText(for: item)
|
||||||
|
let date = formatShortDate(item.updatedAt)
|
||||||
|
let progress = progressFor(item)
|
||||||
|
|
||||||
|
if itemSelectMode {
|
||||||
|
Button {
|
||||||
|
if selectedItemIds.contains(item.id) { selectedItemIds.remove(item.id) }
|
||||||
|
else { selectedItemIds.insert(item.id) }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: selectedItemIds.contains(item.id) ? "checkmark.circle" : "circle")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(selectedItemIds.contains(item.id) ? Color.zxPurple : Color.zxF03)
|
||||||
|
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
} else {
|
||||||
|
NavigationLink(value: Route.knowledgeDetail(item: item)) {
|
||||||
|
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
itemSelectMode = true
|
||||||
|
selectedItemIds.insert(item.id)
|
||||||
|
} label: {
|
||||||
|
Label("多选", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
} label: {
|
||||||
|
Label("重命名", image: "icon-pencil")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
} label: {
|
||||||
|
Label("移动到", image: "icon-folder")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.batchDeleteItems(ids: [item.id])
|
||||||
|
await viewModel.refresh(knowledgeBaseId: knowledgeBaseId)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("删除", image: "icon-trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sourceTabContent() -> some View {
|
||||||
|
if isLoadingSources {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView().tint(Color.zxPurple)
|
||||||
|
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
.padding(.top, 80)
|
||||||
|
} else if sources.isEmpty {
|
||||||
|
Text("暂无资料来源").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
|
||||||
|
} else {
|
||||||
|
ForEach(sources) { src in
|
||||||
|
sourceRowView(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sourceRowView(_ src: KnowledgeSource) -> some View {
|
||||||
|
if sourceSelectMode {
|
||||||
|
Button {
|
||||||
|
if selectedSourceIds.contains(src.id) { selectedSourceIds.remove(src.id) }
|
||||||
|
else { selectedSourceIds.insert(src.id) }
|
||||||
|
} label: {
|
||||||
|
sourceSelectRow(src)
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
} else {
|
||||||
|
NavigationLink(value: Route.materialReader(
|
||||||
|
materialId: src.id,
|
||||||
|
filePath: "",
|
||||||
|
materialType: materialTypeFromMime(src.mimeType, filename: src.originalFilename),
|
||||||
|
knowledgeBaseId: knowledgeBaseId,
|
||||||
|
title: src.originalFilename ?? src.title ?? "未命名"
|
||||||
|
)) {
|
||||||
|
sourceNavigationRow(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sourceSelectRow(_ src: KnowledgeSource) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: selectedSourceIds.contains(src.id) ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(selectedSourceIds.contains(src.id) ? Color.zxPurple : Color.zxF03)
|
||||||
|
Image(systemName: src.type == "file" ? "doc" : "link")
|
||||||
|
.font(.system(size: 18)).foregroundColor(Color.zxPurple)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
sourceMetaColumn(src)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.overlay(alignment: .bottom) { Color.zxHairline.frame(height: 0.5) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sourceNavigationRow(_ src: KnowledgeSource) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: src.type == "file" ? "doc" : "link")
|
||||||
|
.font(.system(size: 18)).foregroundColor(Color.zxPurple)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
sourceMetaColumn(src)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
sourceToDelete = src
|
||||||
|
showSourceDeleteConfirm = true
|
||||||
|
} label: {
|
||||||
|
Image("icon-trash").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.overlay(alignment: .bottom) { Color.zxHairline.frame(height: 0.5) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sourceMetaColumn(_ src: KnowledgeSource) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(src.originalFilename ?? src.title ?? "未命名")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let len = src.textLength, len > 0 {
|
||||||
|
Text("\(len) 字").font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
if let size = src.sizeBytes, size > 0 {
|
||||||
|
Text(formatFileSize(size)).font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
if let date = formatSourceDate(src.updatedAt ?? src.createdAt) {
|
||||||
|
Text(date).font(.system(size: 12)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let badge = sourceStatusBadge(for: src) {
|
||||||
|
statusBadge(text: badge.text, color: badge.color)
|
||||||
|
}
|
||||||
|
if let status = sourceReadingStatus[src.id], status != "not_started" {
|
||||||
|
readingStatusBadge(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
struct ZXCardRow: View { let icon: String; let title: String; let desc: String; let status: String; let c: Color
|
struct ZXCardRow: View { let icon: String; let title: String; let desc: String; let status: String; let c: Color
|
||||||
var body: some View { HStack(spacing: 12) { Image(systemName: icon).font(.system(size: 18)).foregroundColor(c).frame(width: 40, height: 40).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF03) }; Spacer(); Text(status).font(.system(size: 10, weight: .semibold)).foregroundColor(c).padding(.horizontal, 8).padding(.vertical, 2).background(c.opacity(0.12)).clipShape(Capsule()) }
|
var body: some View { HStack(spacing: 12) { Image(systemName: icon).font(.system(size: 18)).foregroundColor(c).frame(width: 40, height: 40).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF03) }; Spacer(); Text(status).font(.system(size: 10, weight: .semibold)).foregroundColor(c).padding(.horizontal, 8).padding(.vertical, 2).background(c.opacity(0.12)).clipShape(Capsule()) }
|
||||||
@ -1111,7 +1410,15 @@ struct KnowledgeDetailPage: View {
|
|||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas)
|
||||||
case .ready:
|
case .ready:
|
||||||
if let path = vm.localPath, let mt = vm.detectedType {
|
if let path = vm.localPath, let mt = vm.detectedType {
|
||||||
MaterialReaderView(materialId: item.id, filePath: path, materialType: mt, title: item.title, showQuickLook: $showQuickLook, showNoteSheet: $showNoteSheet)
|
MaterialReaderView(
|
||||||
|
materialId: item.id,
|
||||||
|
filePath: path,
|
||||||
|
materialType: mt,
|
||||||
|
knowledgeBaseId: item.knowledgeBaseId,
|
||||||
|
title: item.title,
|
||||||
|
showQuickLook: $showQuickLook,
|
||||||
|
showNoteSheet: $showNoteSheet
|
||||||
|
)
|
||||||
} else if let content = item.content {
|
} else if let content = item.content {
|
||||||
// Plain text content — render directly
|
// Plain text content — render directly
|
||||||
ZStack { Color.zxCanvas.ignoresSafeArea()
|
ZStack { Color.zxCanvas.ignoresSafeArea()
|
||||||
@ -1350,137 +1657,6 @@ struct ZXImportRow: View { let icon: String; let title: String; let desc: String
|
|||||||
; VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }
|
; VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Import Review
|
|
||||||
|
|
||||||
struct ImportReviewPage: View {
|
|
||||||
let sourceId: String
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@State private var candidates: [ImportCandidate] = []
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var isProcessing = false
|
|
||||||
@State private var errorMessage: String?
|
|
||||||
@State private var expandedId: String?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
|
|
||||||
if isLoading {
|
|
||||||
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载候选...").font(.system(size: 14)).foregroundColor(Color.zxF04) }
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else if candidates.isEmpty {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "checkmark.circle").font(.system(size: 36)).foregroundColor(Color.zxGreen)
|
|
||||||
Text("没有待审批的候选").font(.system(size: 14)).foregroundColor(Color.zxF04)
|
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
HStack {
|
|
||||||
Text("\(candidates.count) 个候选知识点").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04)
|
|
||||||
Spacer()
|
|
||||||
Button {
|
|
||||||
Task { await batchAccept() }
|
|
||||||
} label: {
|
|
||||||
Text("全部接受").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxPrimary)
|
|
||||||
}
|
|
||||||
.disabled(isProcessing)
|
|
||||||
}
|
|
||||||
ForEach(candidates) { c in
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
Button {
|
|
||||||
withAnimation { expandedId = expandedId == c.id ? nil : c.id }
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
if let conf = c.confidence {
|
|
||||||
Text("\(Int(conf * 100))%").font(.system(size: 10, weight: .bold))
|
|
||||||
.foregroundColor(conf > 0.7 ? Color.zxGreen : Color.zxAmber)
|
|
||||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
||||||
.background((conf > 0.7 ? Color.zxGreen : Color.zxAmber).opacity(0.12))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(c.title ?? "无标题").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1)
|
|
||||||
if let content = c.content {
|
|
||||||
Text(content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: expandedId == c.id ? "chevron.up" : "chevron.down").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
|
||||||
}
|
|
||||||
.padding(12).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
if expandedId == c.id {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Button {
|
|
||||||
Task { await rejectCandidate(c) }
|
|
||||||
} label: {
|
|
||||||
Label("拒绝", systemImage: "xmark").font(.system(size: 14, weight: .medium))
|
|
||||||
.foregroundColor(Color.zxCoral).frame(maxWidth: .infinity).frame(height: 40)
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
Task { await acceptCandidate(c) }
|
|
||||||
} label: {
|
|
||||||
Label("接受", systemImage: "checkmark").font(.system(size: 14, weight: .medium))
|
|
||||||
.foregroundColor(Color.zxGreen).frame(maxWidth: .infinity).frame(height: 40)
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12).padding(.top, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
|
||||||
}
|
|
||||||
.scrollIndicators(.hidden)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
.navigationTitle("候选审批").navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
|
|
||||||
.disabled(isProcessing)
|
|
||||||
.task { await load() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func load() async {
|
|
||||||
isLoading = true; errorMessage = nil
|
|
||||||
do { candidates = try await ImportCandidateService.shared.listBySource(sourceId: sourceId) } catch { errorMessage = error.localizedDescription }
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func acceptCandidate(_ c: ImportCandidate) async {
|
|
||||||
isProcessing = true
|
|
||||||
do {
|
|
||||||
let _ = try await ImportCandidateService.shared.accept(id: c.id)
|
|
||||||
candidates.removeAll { $0.id == c.id }
|
|
||||||
ZXToastManager.shared.success("已接受")
|
|
||||||
} catch { ZXToastManager.shared.error("操作失败") }
|
|
||||||
isProcessing = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rejectCandidate(_ c: ImportCandidate) async {
|
|
||||||
isProcessing = true
|
|
||||||
do {
|
|
||||||
let _ = try await ImportCandidateService.shared.reject(id: c.id)
|
|
||||||
candidates.removeAll { $0.id == c.id }
|
|
||||||
ZXToastManager.shared.success("已拒绝")
|
|
||||||
} catch { ZXToastManager.shared.error("操作失败") }
|
|
||||||
isProcessing = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func batchAccept() async {
|
|
||||||
isProcessing = true
|
|
||||||
do {
|
|
||||||
let _ = try await ImportCandidateService.shared.batchAccept(sourceId: sourceId)
|
|
||||||
candidates.removeAll()
|
|
||||||
ZXToastManager.shared.success("全部接受")
|
|
||||||
} catch { ZXToastManager.shared.error("操作失败") }
|
|
||||||
isProcessing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EditKnowledgePage: View {
|
struct EditKnowledgePage: View {
|
||||||
let item: KnowledgeItem
|
let item: KnowledgeItem
|
||||||
@State private var title: String; @State private var content: String
|
@State private var title: String; @State private var content: String
|
||||||
|
|||||||
@ -91,8 +91,8 @@ final class MaterialDetailViewModel: ObservableObject {
|
|||||||
quizCount = quizzesR?.count ?? 0
|
quizCount = quizzesR?.count ?? 0
|
||||||
if let p = progR {
|
if let p = progR {
|
||||||
readingStatus = p.status
|
readingStatus = p.status
|
||||||
readingSeconds = p.totalActiveSeconds
|
readingSeconds = p.totalActiveSeconds ?? 0
|
||||||
isMarkedRead = p.isMarkedRead
|
isMarkedRead = p.isMarkedRead ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
aiStatus = itemCount > 0 ? .ready : .processing
|
aiStatus = itemCount > 0 ? .ready : .processing
|
||||||
@ -104,23 +104,28 @@ final class MaterialDetailViewModel: ObservableObject {
|
|||||||
|
|
||||||
struct MaterialDetailView: View {
|
struct MaterialDetailView: View {
|
||||||
@StateObject private var vm: MaterialDetailViewModel
|
@StateObject private var vm: MaterialDetailViewModel
|
||||||
|
@State private var isGeneratingQuiz = false
|
||||||
|
@State private var generatedQuizId: String?
|
||||||
|
@State private var showGeneratedQuiz = false
|
||||||
|
|
||||||
let knowledgeBaseId: String
|
let knowledgeBaseId: String
|
||||||
|
let sourceId: String
|
||||||
let fileName: String
|
let fileName: String
|
||||||
let fileType: MaterialType
|
let fileType: MaterialType
|
||||||
let fileSize: UInt64
|
let fileSize: UInt64
|
||||||
let filePath: String
|
let filePath: String
|
||||||
let uploadDate: String?
|
let uploadDate: String?
|
||||||
|
|
||||||
init(knowledgeBaseId: String, fileName: String, fileType: MaterialType,
|
init(knowledgeBaseId: String, sourceId: String, fileName: String, fileType: MaterialType,
|
||||||
fileSize: UInt64, filePath: String, uploadDate: String? = nil) {
|
fileSize: UInt64, filePath: String, uploadDate: String? = nil) {
|
||||||
self.knowledgeBaseId = knowledgeBaseId
|
self.knowledgeBaseId = knowledgeBaseId
|
||||||
|
self.sourceId = sourceId
|
||||||
self.fileName = fileName
|
self.fileName = fileName
|
||||||
self.fileType = fileType
|
self.fileType = fileType
|
||||||
self.fileSize = fileSize
|
self.fileSize = fileSize
|
||||||
self.filePath = filePath
|
self.filePath = filePath
|
||||||
self.uploadDate = uploadDate
|
self.uploadDate = uploadDate
|
||||||
_vm = StateObject(wrappedValue: MaterialDetailViewModel(knowledgeBaseId: knowledgeBaseId))
|
_vm = StateObject(wrappedValue: MaterialDetailViewModel(knowledgeBaseId: knowledgeBaseId, materialId: sourceId))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -146,6 +151,13 @@ struct MaterialDetailView: View {
|
|||||||
.toolbar(.hidden, for: .tabBar)
|
.toolbar(.hidden, for: .tabBar)
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
.task { await vm.load() }
|
.task { await vm.load() }
|
||||||
|
.navigationDestination(isPresented: $showGeneratedQuiz) {
|
||||||
|
if let quizId = generatedQuizId {
|
||||||
|
QuizTakerView(quizId: quizId)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - File section
|
// MARK: - File section
|
||||||
@ -300,6 +312,27 @@ struct MaterialDetailView: View {
|
|||||||
statBox(count: vm.quizCount, label: "测验", icon: "list.bullet.clipboard", color: Color.zxAccent)
|
statBox(count: vm.quizCount, label: "测验", icon: "list.bullet.clipboard", color: Color.zxAccent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await generateSourceQuiz() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if isGeneratingQuiz {
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "sparkles.rectangle.stack")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
Text("基于当前资料生成测验")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 46)
|
||||||
|
.background(ZXGradient.brand)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.disabled(isGeneratingQuiz)
|
||||||
|
|
||||||
if vm.aiStatus == .processing {
|
if vm.aiStatus == .processing {
|
||||||
Text("知习正在整理知识点,完成后可开始学习和自测")
|
Text("知习正在整理知识点,完成后可开始学习和自测")
|
||||||
.font(.system(size: 12)).foregroundColor(Color.zxF03)
|
.font(.system(size: 12)).foregroundColor(Color.zxF03)
|
||||||
@ -322,6 +355,24 @@ struct MaterialDetailView: View {
|
|||||||
.background(Color.zxSurface).clipShape(RoundedRectangle(cornerRadius: 14))
|
.background(Color.zxSurface).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func generateSourceQuiz() async {
|
||||||
|
isGeneratingQuiz = true
|
||||||
|
defer { isGeneratingQuiz = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let quiz = try await QuizService.shared.generate(
|
||||||
|
knowledgeBaseId: knowledgeBaseId,
|
||||||
|
sourceType: "source",
|
||||||
|
sourceId: sourceId,
|
||||||
|
questionCount: 5
|
||||||
|
)
|
||||||
|
generatedQuizId = quiz.id
|
||||||
|
showGeneratedQuiz = true
|
||||||
|
} catch {
|
||||||
|
ZXToastManager.shared.error("生成失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - File size formatter
|
// MARK: - File size formatter
|
||||||
|
|||||||
@ -94,28 +94,40 @@ final class MaterialReaderViewModel: ObservableObject {
|
|||||||
// MARK: - Main View
|
// MARK: - Main View
|
||||||
|
|
||||||
struct MaterialReaderView: View {
|
struct MaterialReaderView: View {
|
||||||
|
private enum BootstrapState: Equatable {
|
||||||
|
case idle
|
||||||
|
case bootstrapping
|
||||||
|
case ready
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
@StateObject private var vm: MaterialReaderViewModel
|
@StateObject private var vm: MaterialReaderViewModel
|
||||||
@Binding var showQuickLook: Bool
|
@Binding var showQuickLook: Bool
|
||||||
@Binding var showNoteSheet: Bool
|
@Binding var showNoteSheet: Bool
|
||||||
@State private var scrollProgress: CGFloat = 0
|
@State private var scrollProgress: CGFloat = 0
|
||||||
@State private var hasRestoredPosition = false
|
@State private var bootstrapState: BootstrapState = .idle
|
||||||
@State private var restoreBlockId: String?
|
@State private var pendingRestoreBlockId: String?
|
||||||
|
@State private var isInitialRestoreComplete = false
|
||||||
|
@State private var isProgrammaticScrollInFlight = false
|
||||||
@State private var actualContentHeight: CGFloat = 1
|
@State private var actualContentHeight: CGFloat = 1
|
||||||
@State private var isMarkedRead = false
|
@State private var isMarkedRead = false
|
||||||
@State private var readingStatus: String?
|
@State private var readingStatus: String?
|
||||||
@State private var readingProgressSeconds: Int = 0
|
@State private var readingProgressSeconds: Int = 0
|
||||||
@State private var apiRestorePosition: ReadingPosition?
|
@State private var apiRestorePosition: ReadingPosition?
|
||||||
|
@State private var localRestoreCheckpoint: ReadingCheckpoint?
|
||||||
@State private var flushTimer: Timer?
|
@State private var flushTimer: Timer?
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var isParsing = false
|
@State private var isParsing = false
|
||||||
@State private var parseMessage = ""
|
@State private var parseMessage = ""
|
||||||
|
@State private var showParseOverlay = false
|
||||||
|
@State private var latestKnowledgeTask: KnowledgeGenerationTaskStatus?
|
||||||
|
@State private var knowledgeTaskPollingTask: Task<Void, Never>?
|
||||||
private let title: String
|
private let title: String
|
||||||
private let knowledgeBaseId: String?
|
private let knowledgeBaseId: String?
|
||||||
|
|
||||||
// V2 session manager (primary) + V1 collector (fallback)
|
// V2 session manager
|
||||||
private let sessionManager = ReadingRuntimeSessionManager.shared
|
private let sessionManager = ReadingRuntimeSessionManager.shared
|
||||||
private let collector = ReadingEventCollector.shared
|
|
||||||
private let positionStore = ReadingPositionStore.shared
|
private let positionStore = ReadingPositionStore.shared
|
||||||
private let readingAPI = ReadingAPIService.shared
|
private let readingAPI = ReadingAPIService.shared
|
||||||
|
|
||||||
@ -124,13 +136,16 @@ struct MaterialReaderView: View {
|
|||||||
sessionManager.state == .active || sessionManager.state == .paused
|
sessionManager.state == .active || sessionManager.state == .paused
|
||||||
}
|
}
|
||||||
|
|
||||||
init(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", showQuickLook: Binding<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .constant(false)) {
|
init(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", resumePosition: ReadingPositionValue? = nil, showQuickLook: Binding<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .constant(false)) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.knowledgeBaseId = knowledgeBaseId
|
self.knowledgeBaseId = knowledgeBaseId
|
||||||
self._showQuickLook = showQuickLook
|
self._showQuickLook = showQuickLook
|
||||||
self._showNoteSheet = showNoteSheet
|
self._showNoteSheet = showNoteSheet
|
||||||
_vm = StateObject(wrappedValue: MaterialReaderViewModel(
|
_vm = StateObject(wrappedValue: MaterialReaderViewModel(
|
||||||
materialId: materialId, filePath: filePath, materialType: materialType))
|
materialId: materialId, filePath: filePath, materialType: materialType))
|
||||||
|
if let rp = resumePosition {
|
||||||
|
_apiRestorePosition = State(initialValue: ReadingPositionAdapter.fromValue(rp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -147,7 +162,7 @@ struct MaterialReaderView: View {
|
|||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "exclamationmark.triangle").font(.system(size: 40)).foregroundColor(Color.zxCoral)
|
Image(systemName: "exclamationmark.triangle").font(.system(size: 40)).foregroundColor(Color.zxCoral)
|
||||||
Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04)
|
Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04)
|
||||||
Button("重试") { Task { await vm.load() } }
|
Button("重试") { Task { await bootstrapReader(forceReload: true) } }
|
||||||
.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary)
|
.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary)
|
||||||
.padding(.horizontal, 24).padding(.vertical, 10)
|
.padding(.horizontal, 24).padding(.vertical, 10)
|
||||||
.background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10))
|
.background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
@ -171,56 +186,41 @@ struct MaterialReaderView: View {
|
|||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 4) {
|
||||||
Button {
|
Button { markAsRead() } label: {
|
||||||
markAsRead()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: isMarkedRead ? "checkmark.circle.fill" : "checkmark.circle")
|
Image(systemName: isMarkedRead ? "checkmark.circle.fill" : "checkmark.circle")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
.foregroundColor(isMarkedRead ? Color.green : Color.zxF05)
|
.foregroundColor(isMarkedRead ? Color.green : Color.zxF05)
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink(value: Route.aiChat(context: ChatEntryContext(
|
|
||||||
scopeType: .material,
|
|
||||||
scopeId: vm.materialId,
|
|
||||||
scopeName: title,
|
|
||||||
parentKnowledgeBaseId: knowledgeBaseId,
|
|
||||||
createdFrom: "material_reader"
|
|
||||||
))) {
|
|
||||||
Image(systemName: "bubble.left.and.bubble.right")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(Color.zxF05)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let kbId = knowledgeBaseId {
|
if let kbId = knowledgeBaseId {
|
||||||
|
Menu {
|
||||||
|
NavigationLink(value: Route.aiChat(context: ChatEntryContext(
|
||||||
|
scopeType: .material, scopeId: vm.materialId, scopeName: title,
|
||||||
|
parentKnowledgeBaseId: knowledgeBaseId, createdFrom: "material_reader"
|
||||||
|
))) {
|
||||||
|
Label("AI 对话", systemImage: "bubble.left.and.bubble.right")
|
||||||
|
}
|
||||||
NavigationLink(value: Route.materialDetail(
|
NavigationLink(value: Route.materialDetail(
|
||||||
knowledgeBaseId: kbId,
|
knowledgeBaseId: kbId, sourceId: vm.materialId, fileName: title.isEmpty ? "资料" : title,
|
||||||
fileName: title.isEmpty ? "资料" : title,
|
fileType: vm.materialType, fileSize: 0, filePath: vm.filePath, uploadDate: nil
|
||||||
fileType: vm.materialType,
|
|
||||||
fileSize: 0,
|
|
||||||
filePath: vm.filePath,
|
|
||||||
uploadDate: nil
|
|
||||||
)) {
|
)) {
|
||||||
Image(systemName: "info.circle")
|
Label("资料详情", systemImage: "info.circle")
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(Color.zxF05)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink(value: Route.learningSession(
|
NavigationLink(value: Route.learningSession(
|
||||||
taskTitle: title.isEmpty ? "学习资料" : title,
|
taskTitle: title.isEmpty ? "学习资料" : title, taskType: "study", taskColorHex: "#3D7FFB"
|
||||||
taskType: "study",
|
|
||||||
taskColorHex: "#3D7FFB"
|
|
||||||
)) {
|
)) {
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
Label("学习会话", systemImage: "arrow.triangle.2.circlepath")
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(Color.zxF05)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task { await triggerParse(kbId: kbId, sourceId: vm.materialId) }
|
Task { await generateKnowledgePoints(kbId: kbId, sourceId: vm.materialId) }
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "brain.head.profile")
|
Label(generateKnowledgeMenuTitle, systemImage: "brain.head.profile")
|
||||||
.font(.system(size: 16))
|
}
|
||||||
|
.disabled(isKnowledgeTaskActive)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
.font(.system(size: 20))
|
||||||
.foregroundColor(Color.zxF05)
|
.foregroundColor(Color.zxF05)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -228,9 +228,9 @@ struct MaterialReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if isParsing {
|
if showParseOverlay {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView().tint(.white)
|
if isParsing { ProgressView().tint(.white) }
|
||||||
Text(parseMessage).font(.system(size: 13, weight: .medium)).foregroundColor(.white)
|
Text(parseMessage).font(.system(size: 13, weight: .medium)).foregroundColor(.white)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16).padding(.vertical, 10)
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||||
@ -240,29 +240,11 @@ struct MaterialReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
if vm.filePath.isEmpty || !FileManager.default.fileExists(atPath: vm.filePath) {
|
await bootstrapReader()
|
||||||
if let kbId = knowledgeBaseId {
|
await refreshKnowledgeTaskStatus()
|
||||||
print("[MaterialReader] No local file, downloading from COS: \(vm.materialId)")
|
restartKnowledgeTaskPollingIfNeeded()
|
||||||
do {
|
|
||||||
let source = try await KnowledgeSourceService.shared.detail(kbId: kbId, id: vm.materialId)
|
|
||||||
if let fileId = source.fileId {
|
|
||||||
let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId)
|
|
||||||
let url = URL(string: downloadUrl)!
|
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
|
||||||
let ext = source.originalFilename?.split(separator: ".").last.map(String.init) ?? "md"
|
|
||||||
let localURL = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("source_\(vm.materialId).\(ext)")
|
|
||||||
try data.write(to: localURL)
|
|
||||||
print("[MaterialReader] Downloaded to: \(localURL.path), size: \(data.count)")
|
|
||||||
vm.filePath = localURL.path
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("[MaterialReader] COS download failed: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await vm.load()
|
|
||||||
}
|
}
|
||||||
|
.onDisappear { stopKnowledgeTaskPolling() }
|
||||||
.sheet(isPresented: $showQuickLook) {
|
.sheet(isPresented: $showQuickLook) {
|
||||||
QuickLookPreview(url: URL(fileURLWithPath: vm.filePath))
|
QuickLookPreview(url: URL(fileURLWithPath: vm.filePath))
|
||||||
}
|
}
|
||||||
@ -274,16 +256,16 @@ struct MaterialReaderView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
openReadingSession()
|
|
||||||
hasRestoredPosition = false
|
|
||||||
// 定时发送阅读事件(每 2 分钟),防止长时间阅读不退出导致数据丢失
|
// 定时发送阅读事件(每 2 分钟),防止长时间阅读不退出导致数据丢失
|
||||||
|
if flushTimer == nil {
|
||||||
flushTimer = Timer.scheduledTimer(withTimeInterval: 120, repeats: true) { _ in
|
flushTimer = Timer.scheduledTimer(withTimeInterval: 120, repeats: true) { _ in
|
||||||
Task {
|
Task {
|
||||||
ReadingEventUploadPipeline.shared.exportAndEnqueue()
|
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
|
||||||
await ReadingEventUploadPipeline.shared.flush()
|
await ReadingEventUploadPipeline.shared.flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
flushTimer?.invalidate()
|
flushTimer?.invalidate()
|
||||||
flushTimer = nil
|
flushTimer = nil
|
||||||
@ -292,7 +274,7 @@ struct MaterialReaderView: View {
|
|||||||
// 2. 导出所有事件(含 material_closed)+ 发送到后端
|
// 2. 导出所有事件(含 material_closed)+ 发送到后端
|
||||||
// 3. 发送完成后再清理 context
|
// 3. 发送完成后再清理 context
|
||||||
Task {
|
Task {
|
||||||
ReadingEventUploadPipeline.shared.exportAndEnqueue()
|
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
|
||||||
await ReadingEventUploadPipeline.shared.flush()
|
await ReadingEventUploadPipeline.shared.flush()
|
||||||
sessionManager.cleanup()
|
sessionManager.cleanup()
|
||||||
}
|
}
|
||||||
@ -306,7 +288,7 @@ struct MaterialReaderView: View {
|
|||||||
}
|
}
|
||||||
// Flush: export → enqueue
|
// Flush: export → enqueue
|
||||||
Task {
|
Task {
|
||||||
ReadingEventUploadPipeline.shared.exportAndEnqueue()
|
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
|
||||||
await ReadingEventUploadPipeline.shared.flush()
|
await ReadingEventUploadPipeline.shared.flush()
|
||||||
}
|
}
|
||||||
case .active:
|
case .active:
|
||||||
@ -320,10 +302,12 @@ struct MaterialReaderView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: vm.loadingState) { _, newState in
|
.onChange(of: apiRestorePosition) { _, newPos in
|
||||||
if newState == .loaded, !hasRestoredPosition {
|
if newPos != nil,
|
||||||
restorePosition()
|
vm.loadingState == .loaded,
|
||||||
hasRestoredPosition = true
|
!isInitialRestoreComplete,
|
||||||
|
pendingRestoreBlockId == nil {
|
||||||
|
restoreInitialPositionIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -393,13 +377,12 @@ struct MaterialReaderView: View {
|
|||||||
scrollProgress = min(1, max(0, -offset / max(actualContentHeight, 1)))
|
scrollProgress = min(1, max(0, -offset / max(actualContentHeight, 1)))
|
||||||
reportScrollPosition()
|
reportScrollPosition()
|
||||||
}
|
}
|
||||||
.onChange(of: restoreBlockId) { _, target in
|
.onAppear {
|
||||||
if let id = target {
|
consumePendingRestore(using: scrollProxy)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
||||||
withAnimation { scrollProxy.scrollTo(id, anchor: .top) }
|
|
||||||
}
|
|
||||||
restoreBlockId = nil
|
|
||||||
}
|
}
|
||||||
|
.onChange(of: pendingRestoreBlockId) { _, target in
|
||||||
|
guard target != nil else { return }
|
||||||
|
consumePendingRestore(using: scrollProxy)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
}
|
}
|
||||||
@ -408,65 +391,68 @@ struct MaterialReaderView: View {
|
|||||||
// MARK: - Reading Session Lifecycle
|
// MARK: - Reading Session Lifecycle
|
||||||
|
|
||||||
private func openReadingSession() {
|
private func openReadingSession() {
|
||||||
// Prevent duplicate open
|
let fileName = URL(fileURLWithPath: vm.filePath).lastPathComponent
|
||||||
guard !isV2Active else {
|
|
||||||
print("[READER] V2 session already active, skipping open")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
|
|
||||||
let context = ReadingMaterialContext(
|
let context = ReadingMaterialContext(
|
||||||
readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile,
|
readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile,
|
||||||
materialId: vm.materialId,
|
materialId: vm.materialId,
|
||||||
knowledgeBaseId: knowledgeBaseId,
|
knowledgeBaseId: knowledgeBaseId,
|
||||||
title: title.isEmpty ? nil : title
|
title: title.isEmpty ? nil : title,
|
||||||
|
materialType: vm.materialType,
|
||||||
|
filePath: vm.filePath,
|
||||||
|
fileName: fileName,
|
||||||
|
fileType: vm.materialType.fileTypeString
|
||||||
)
|
)
|
||||||
|
|
||||||
// V2 primary
|
// V2 session
|
||||||
if let _ = try? sessionManager.openMaterial(context) {
|
do {
|
||||||
|
try sessionManager.openMaterial(context)
|
||||||
print("[READER] V2 session opened — materialId=\(vm.materialId)")
|
print("[READER] V2 session opened — materialId=\(vm.materialId)")
|
||||||
} else {
|
} catch {
|
||||||
print("[READER] V2 unavailable, falling back to V1")
|
print("[READER] V2 session open FAILED: \(error) — materialId=\(vm.materialId)")
|
||||||
collector.open(materialId: vm.materialId)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query reading progress from API
|
private func bootstrapReader(forceReload: Bool = false) async {
|
||||||
Task {
|
if bootstrapState == .bootstrapping {
|
||||||
do {
|
return
|
||||||
let progress = try await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType)
|
|
||||||
await MainActor.run {
|
|
||||||
readingStatus = progress.status
|
|
||||||
readingProgressSeconds = progress.totalActiveSeconds
|
|
||||||
if progress.isMarkedRead { isMarkedRead = true; MarkedReadStore.shared.mark(vm.materialId) }
|
|
||||||
if let lp = progress.lastPosition {
|
|
||||||
apiRestorePosition = ReadingPositionAdapter.fromValue(lp)
|
|
||||||
}
|
}
|
||||||
|
if bootstrapState == .ready && !forceReload {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Fallback to local cache when API unavailable
|
bootstrapState = .bootstrapping
|
||||||
if MarkedReadStore.shared.contains(vm.materialId) { isMarkedRead = true }
|
pendingRestoreBlockId = nil
|
||||||
apiRestorePosition = positionStore.load(materialId: vm.materialId)
|
isProgrammaticScrollInFlight = false
|
||||||
print("[READER] Progress query failed, using local position: \(error)")
|
isInitialRestoreComplete = false
|
||||||
|
|
||||||
|
await ensureLocalFileIfNeeded()
|
||||||
|
await vm.load()
|
||||||
|
|
||||||
|
guard vm.loadingState == .loaded else {
|
||||||
|
if case .error(let message) = vm.loadingState {
|
||||||
|
bootstrapState = .failed(message)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openReadingSession()
|
||||||
|
await fetchReadingProgress()
|
||||||
|
restoreInitialPositionIfNeeded()
|
||||||
|
if pendingRestoreBlockId == nil {
|
||||||
|
isInitialRestoreComplete = true
|
||||||
|
}
|
||||||
|
bootstrapState = .ready
|
||||||
}
|
}
|
||||||
|
|
||||||
private func closeReadingSession() {
|
private func closeReadingSession() {
|
||||||
// V2 primary
|
// V2 session
|
||||||
if sessionManager.state == .active || sessionManager.state == .paused {
|
if sessionManager.state == .active || sessionManager.state == .paused {
|
||||||
if let lastPos = sessionManager.lastPosition {
|
if let lastPos = sessionManager.lastPosition {
|
||||||
positionStore.save(materialId: vm.materialId, position: lastPos)
|
positionStore.save(materialId: vm.materialId, position: lastPos)
|
||||||
}
|
}
|
||||||
sessionManager.closeMaterial()
|
sessionManager.closeMaterial()
|
||||||
print("[READER] V2 session closed")
|
print("[READER] V2 session closed")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// V1 fallback
|
|
||||||
if let lastPos = collector.lastPosition {
|
|
||||||
positionStore.save(materialId: vm.materialId, position: lastPos)
|
|
||||||
}
|
|
||||||
_ = collector.close(materialId: vm.materialId)
|
|
||||||
print("[READER] V1 session closed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark the current material as read — optimistic update + event push.
|
/// Mark the current material as read — optimistic update + event push.
|
||||||
@ -474,58 +460,165 @@ struct MaterialReaderView: View {
|
|||||||
guard !isMarkedRead else { return }
|
guard !isMarkedRead else { return }
|
||||||
isMarkedRead = true
|
isMarkedRead = true
|
||||||
MarkedReadStore.shared.mark(vm.materialId)
|
MarkedReadStore.shared.mark(vm.materialId)
|
||||||
if isV2Active {
|
|
||||||
sessionManager.markAsRead()
|
sessionManager.markAsRead()
|
||||||
} else {
|
|
||||||
collector.markAsRead(materialId: vm.materialId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a NoteAnchor from the current scroll position (for quick note).
|
/// Build a NoteAnchor from the current scroll position (for quick note).
|
||||||
private func buildAnchor() -> NoteAnchor? {
|
private func buildAnchor() -> NoteAnchor? {
|
||||||
let pos = sessionManager.lastPosition ?? collector.lastPosition
|
let pos = sessionManager.lastPosition
|
||||||
guard let pos else { return nil }
|
guard let pos else { return nil }
|
||||||
return createNoteAnchor(materialId: vm.materialId, position: pos)
|
return createNoteAnchor(materialId: vm.materialId, position: pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore saved reading position on re-entry.
|
private func ensureLocalFileIfNeeded() async {
|
||||||
/// Priority: API lastPosition > local positionStore > session lastPosition
|
guard vm.filePath.isEmpty || !FileManager.default.fileExists(atPath: vm.filePath) else {
|
||||||
private func restorePosition() {
|
return
|
||||||
// If API already fetched a position, use it immediately
|
}
|
||||||
if let apiPos = apiRestorePosition {
|
guard let kbId = knowledgeBaseId else {
|
||||||
applyPosition(apiPos)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: local store
|
print("[MaterialReader] No local file, downloading from COS: \(vm.materialId)")
|
||||||
|
do {
|
||||||
|
let source = try await KnowledgeSourceService.shared.detail(kbId: kbId, id: vm.materialId)
|
||||||
|
if let fileId = source.fileId {
|
||||||
|
let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId)
|
||||||
|
let url = URL(string: downloadUrl)!
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
let ext = source.originalFilename?.split(separator: ".").last.map(String.init) ?? "md"
|
||||||
|
let localURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("source_\(vm.materialId).\(ext)")
|
||||||
|
try data.write(to: localURL)
|
||||||
|
print("[MaterialReader] Downloaded to: \(localURL.path), size: \(data.count)")
|
||||||
|
vm.filePath = localURL.path
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[MaterialReader] COS download failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchReadingProgress() async {
|
||||||
|
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
|
||||||
|
|
||||||
|
do {
|
||||||
|
let progress = try await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType)
|
||||||
|
readingStatus = progress.status
|
||||||
|
readingProgressSeconds = progress.totalActiveSeconds ?? 0
|
||||||
|
if progress.isMarkedRead == true {
|
||||||
|
isMarkedRead = true
|
||||||
|
MarkedReadStore.shared.mark(vm.materialId)
|
||||||
|
}
|
||||||
|
if let lp = progress.lastPosition {
|
||||||
|
apiRestorePosition = ReadingPositionAdapter.fromValue(lp)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if MarkedReadStore.shared.contains(vm.materialId) {
|
||||||
|
isMarkedRead = true
|
||||||
|
}
|
||||||
|
localRestoreCheckpoint = positionStore.loadCheckpoint(materialId: vm.materialId)
|
||||||
|
apiRestorePosition = localRestoreCheckpoint?.position ?? positionStore.load(materialId: vm.materialId)
|
||||||
|
print("[READER] Progress query failed, using local position: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore saved reading position on re-entry.
|
||||||
|
/// Same-device local checkpoints are preferred over API progress because
|
||||||
|
/// the home "continue learning" flow can reopen the reader before the
|
||||||
|
/// latest uploaded position has been processed server-side.
|
||||||
|
private func restoreInitialPositionIfNeeded() {
|
||||||
|
guard !isInitialRestoreComplete else { return }
|
||||||
|
|
||||||
|
if let checkpoint = localRestoreCheckpoint,
|
||||||
|
sessionManager.isCompatibleRestoreCheckpoint(checkpoint) {
|
||||||
|
applyPosition(checkpoint.position)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let checkpoint = positionStore.loadCheckpoint(materialId: vm.materialId),
|
||||||
|
sessionManager.isCompatibleRestoreCheckpoint(checkpoint) {
|
||||||
|
localRestoreCheckpoint = checkpoint
|
||||||
|
applyPosition(checkpoint.position)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if localRestoreCheckpoint != nil {
|
||||||
|
print("[READER] Skip incompatible local checkpoint — materialId=\(vm.materialId)")
|
||||||
|
localRestoreCheckpoint = nil
|
||||||
|
}
|
||||||
|
|
||||||
if let saved = positionStore.load(materialId: vm.materialId) {
|
if let saved = positionStore.load(materialId: vm.materialId) {
|
||||||
applyPosition(saved)
|
applyPosition(saved)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: session/collector
|
if let apiPos = apiRestorePosition {
|
||||||
if let pos = sessionManager.lastPosition ?? collector.lastPosition {
|
applyPosition(apiPos)
|
||||||
applyPosition(pos)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let pos = sessionManager.lastPosition {
|
||||||
|
applyPosition(pos)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitialRestoreComplete = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyPosition(_ pos: ReadingPosition) {
|
private func applyPosition(_ pos: ReadingPosition) {
|
||||||
switch pos {
|
if let restoreBlockId = ReadingPositionAdapter.blockIdForRestore(from: pos, in: vm.blocks) {
|
||||||
case .markdown(let blockId, _):
|
pendingRestoreBlockId = restoreBlockId
|
||||||
restoreBlockId = blockId
|
return
|
||||||
case .text(let lineNumber, _):
|
}
|
||||||
let idx = Int(lineNumber) - 1
|
|
||||||
if idx >= 0, idx < vm.blocks.count {
|
switch pos {
|
||||||
restoreBlockId = ReadingPositionAdapter.blockId(from: vm.blocks[idx])
|
case .block, .markdown, .text:
|
||||||
|
isInitialRestoreComplete = true
|
||||||
|
print("[READER] Restore target missing, skipped — materialId=\(vm.materialId)")
|
||||||
|
case .pdf, .pdfViewport, .image, .imageViewport, .workbook, .slide, .epub, .unknown:
|
||||||
|
isInitialRestoreComplete = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmRestoredPosition() {
|
||||||
|
guard let restoreBlockId = pendingRestoreBlockId else { return }
|
||||||
|
guard let restoredIndex = ReadingPositionAdapter.blockIndex(for: restoreBlockId, in: vm.blocks) else { return }
|
||||||
|
let restoredProgress: Float
|
||||||
|
if vm.blocks.count <= 1 {
|
||||||
|
restoredProgress = 0
|
||||||
|
} else {
|
||||||
|
restoredProgress = Float(restoredIndex) / Float(vm.blocks.count - 1)
|
||||||
|
}
|
||||||
|
guard let position = ReadingPositionAdapter.fromBlockScroll(
|
||||||
|
materialType: vm.materialType,
|
||||||
|
blocks: vm.blocks,
|
||||||
|
scrollProgress: restoredProgress,
|
||||||
|
blockIndex: restoredIndex
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
if sessionManager.state == .active {
|
||||||
|
sessionManager.updatePosition(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func consumePendingRestore(using scrollProxy: ScrollViewProxy) {
|
||||||
|
guard let restoreBlockId = pendingRestoreBlockId else { return }
|
||||||
|
isProgrammaticScrollInFlight = true
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
scrollProxy.scrollTo(restoreBlockId, anchor: .top)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
confirmRestoredPosition()
|
||||||
|
pendingRestoreBlockId = nil
|
||||||
|
isProgrammaticScrollInFlight = false
|
||||||
|
isInitialRestoreComplete = true
|
||||||
}
|
}
|
||||||
case .pdf, .image, .epub, .unknown:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report current scroll position to the active session manager.
|
/// Report current scroll position to the active session manager.
|
||||||
private func reportScrollPosition() {
|
private func reportScrollPosition() {
|
||||||
guard !vm.blocks.isEmpty else { return }
|
guard !vm.blocks.isEmpty else { return }
|
||||||
|
guard isInitialRestoreComplete else { return }
|
||||||
|
guard !isProgrammaticScrollInFlight else { return }
|
||||||
|
|
||||||
let idx = max(0, min(vm.blocks.count - 1,
|
let idx = max(0, min(vm.blocks.count - 1,
|
||||||
Int(scrollProgress * CGFloat(vm.blocks.count))))
|
Int(scrollProgress * CGFloat(vm.blocks.count))))
|
||||||
@ -541,9 +634,6 @@ struct MaterialReaderView: View {
|
|||||||
// V2 primary
|
// V2 primary
|
||||||
if sessionManager.state == .active {
|
if sessionManager.state == .active {
|
||||||
sessionManager.updatePosition(pos)
|
sessionManager.updatePosition(pos)
|
||||||
} else {
|
|
||||||
// V1 fallback
|
|
||||||
collector.updatePosition(materialId: vm.materialId, position: pos)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -612,19 +702,80 @@ struct MaterialReaderView: View {
|
|||||||
|
|
||||||
// MARK: - Parse Trigger
|
// MARK: - Parse Trigger
|
||||||
|
|
||||||
private func triggerParse(kbId: String, sourceId: String) async {
|
private func generateKnowledgePoints(kbId: String, sourceId: String) async {
|
||||||
guard !isParsing else { return }
|
guard !isParsing, !isKnowledgeTaskActive else { return }
|
||||||
isParsing = true
|
isParsing = true
|
||||||
parseMessage = "AI 正在整理知识点..."
|
showParseOverlay = true
|
||||||
|
parseMessage = "正在提交知识点生成任务..."
|
||||||
do {
|
do {
|
||||||
let result = try await KnowledgeSourceService.shared.generateKnowledge(kbId: kbId, sourceId: sourceId)
|
let result = try await KnowledgeSourceService.shared.generateKnowledge(kbId: kbId, sourceId: sourceId)
|
||||||
parseMessage = result.count > 0
|
parseMessage = result.message
|
||||||
? "整理完成,生成了 \(result.count) 个知识点"
|
latestKnowledgeTask = result.asTaskStatus
|
||||||
: "未识别到可提取的知识点"
|
ZXToastManager.shared.success(result.message)
|
||||||
|
restartKnowledgeTaskPollingIfNeeded()
|
||||||
} catch {
|
} catch {
|
||||||
parseMessage = "整理失败: \(error.localizedDescription)"
|
parseMessage = "生成失败: \(error.localizedDescription)"
|
||||||
|
ZXToastManager.shared.error(parseMessage)
|
||||||
}
|
}
|
||||||
isParsing = false
|
isParsing = false
|
||||||
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
if !isParsing { showParseOverlay = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var generateKnowledgeMenuTitle: String {
|
||||||
|
switch latestKnowledgeTask?.status {
|
||||||
|
case "waiting_for_parse":
|
||||||
|
return "等待解析中"
|
||||||
|
case "queued":
|
||||||
|
return "已入队"
|
||||||
|
case "running":
|
||||||
|
return "生成中"
|
||||||
|
case "completed":
|
||||||
|
return "重新生成知识点"
|
||||||
|
case "failed":
|
||||||
|
return "重新生成知识点"
|
||||||
|
default:
|
||||||
|
return "生成知识点"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isKnowledgeTaskActive: Bool {
|
||||||
|
guard let status = latestKnowledgeTask?.status else { return false }
|
||||||
|
return ["waiting_for_parse", "queued", "running"].contains(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshKnowledgeTaskStatus() async {
|
||||||
|
guard let kbId = knowledgeBaseId else { return }
|
||||||
|
do {
|
||||||
|
latestKnowledgeTask = try await KnowledgeSourceService.shared.latestKnowledgeGenerationTask(
|
||||||
|
kbId: kbId,
|
||||||
|
sourceId: vm.materialId
|
||||||
|
)
|
||||||
|
if let latestKnowledgeTask {
|
||||||
|
parseMessage = latestKnowledgeTask.message
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[MaterialReader] refreshKnowledgeTaskStatus ERROR: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartKnowledgeTaskPollingIfNeeded() {
|
||||||
|
stopKnowledgeTaskPolling()
|
||||||
|
guard isKnowledgeTaskActive else { return }
|
||||||
|
knowledgeTaskPollingTask = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await refreshKnowledgeTaskStatus()
|
||||||
|
if !isKnowledgeTaskActive { break }
|
||||||
|
}
|
||||||
|
knowledgeTaskPollingTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopKnowledgeTaskPolling() {
|
||||||
|
knowledgeTaskPollingTask?.cancel()
|
||||||
|
knowledgeTaskPollingTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Unsupported
|
// MARK: - Unsupported
|
||||||
|
|||||||
@ -24,10 +24,21 @@ struct ReadingPositionAdapter {
|
|||||||
switch materialType {
|
switch materialType {
|
||||||
case .markdown:
|
case .markdown:
|
||||||
guard let bid = blockId else { return nil }
|
guard let bid = blockId else { return nil }
|
||||||
return .markdown(blockId: bid, scrollProgress: clamp(scrollProgress ?? 0))
|
return .block(
|
||||||
|
blockId: bid,
|
||||||
|
blockProgress: clamp(scrollProgress ?? 0),
|
||||||
|
overallProgress: clamp(overallProgress ?? scrollProgress ?? 0)
|
||||||
|
)
|
||||||
|
|
||||||
case .text:
|
case .text:
|
||||||
|
guard let bid = blockId else {
|
||||||
return .text(lineNumber: lineNumber ?? 1, scrollProgress: clamp(scrollProgress ?? 0))
|
return .text(lineNumber: lineNumber ?? 1, scrollProgress: clamp(scrollProgress ?? 0))
|
||||||
|
}
|
||||||
|
return .block(
|
||||||
|
blockId: bid,
|
||||||
|
blockProgress: clamp(scrollProgress ?? 0),
|
||||||
|
overallProgress: clamp(overallProgress ?? scrollProgress ?? 0)
|
||||||
|
)
|
||||||
|
|
||||||
case .pdf:
|
case .pdf:
|
||||||
return .pdf(
|
return .pdf(
|
||||||
@ -77,6 +88,13 @@ struct ReadingPositionAdapter {
|
|||||||
/// Convert an API `ReadingPositionValue` DTO into a `ReadingPosition` enum.
|
/// Convert an API `ReadingPositionValue` DTO into a `ReadingPosition` enum.
|
||||||
static func fromValue(_ v: ReadingPositionValue) -> ReadingPosition? {
|
static func fromValue(_ v: ReadingPositionValue) -> ReadingPosition? {
|
||||||
switch v.type {
|
switch v.type {
|
||||||
|
case "Block":
|
||||||
|
guard let bid = v.blockId else { return nil }
|
||||||
|
return .block(
|
||||||
|
blockId: bid,
|
||||||
|
blockProgress: clamp(v.blockProgress ?? 0),
|
||||||
|
overallProgress: clamp(v.overallProgress ?? 0)
|
||||||
|
)
|
||||||
case "Markdown":
|
case "Markdown":
|
||||||
guard let bid = v.blockId else { return nil }
|
guard let bid = v.blockId else { return nil }
|
||||||
return .markdown(blockId: bid, scrollProgress: clamp(v.scrollProgress ?? 0))
|
return .markdown(blockId: bid, scrollProgress: clamp(v.scrollProgress ?? 0))
|
||||||
@ -94,6 +112,50 @@ struct ReadingPositionAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func checkpointPosition(from position: ReadingPosition) -> ReadingPosition {
|
||||||
|
switch position {
|
||||||
|
case .markdown(let blockId, let scrollProgress):
|
||||||
|
return .block(
|
||||||
|
blockId: blockId,
|
||||||
|
blockProgress: clamp(scrollProgress),
|
||||||
|
overallProgress: clamp(scrollProgress)
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func blockIndex(for blockId: String, in blocks: [DocumentBlock]) -> Int? {
|
||||||
|
blocks.firstIndex { candidate in
|
||||||
|
self.blockId(from: candidate) == blockId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func blockIdForRestore(from position: ReadingPosition, in blocks: [DocumentBlock]) -> String? {
|
||||||
|
guard !blocks.isEmpty else { return nil }
|
||||||
|
|
||||||
|
switch position {
|
||||||
|
case .block(let blockId, _, let overallProgress):
|
||||||
|
if blockIndex(for: blockId, in: blocks) != nil {
|
||||||
|
return blockId
|
||||||
|
}
|
||||||
|
return fallbackBlockId(for: overallProgress, in: blocks)
|
||||||
|
case .markdown(let blockId, let scrollProgress):
|
||||||
|
if blockIndex(for: blockId, in: blocks) != nil {
|
||||||
|
return blockId
|
||||||
|
}
|
||||||
|
return fallbackBlockId(for: scrollProgress, in: blocks)
|
||||||
|
case .text(let lineNumber, let scrollProgress):
|
||||||
|
let lineIndex = Int(lineNumber) - 1
|
||||||
|
if lineIndex >= 0, lineIndex < blocks.count {
|
||||||
|
return blockId(from: blocks[lineIndex])
|
||||||
|
}
|
||||||
|
return fallbackBlockId(for: scrollProgress, in: blocks)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private static func clamp(_ value: Float) -> Float {
|
private static func clamp(_ value: Float) -> Float {
|
||||||
@ -102,6 +164,14 @@ struct ReadingPositionAdapter {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func fallbackBlockId(for progress: Float, in blocks: [DocumentBlock]) -> String? {
|
||||||
|
guard !blocks.isEmpty else { return nil }
|
||||||
|
let normalized = clamp(progress)
|
||||||
|
let scaled = normalized * Float(max(blocks.count - 1, 0))
|
||||||
|
let index = min(blocks.count - 1, max(0, Int(round(scaled))))
|
||||||
|
return blockId(from: blocks[index])
|
||||||
|
}
|
||||||
|
|
||||||
static func blockId(from block: DocumentBlock) -> String {
|
static func blockId(from block: DocumentBlock) -> String {
|
||||||
switch block {
|
switch block {
|
||||||
case .heading(let id, _, _): return id
|
case .heading(let id, _, _): return id
|
||||||
|
|||||||
@ -21,12 +21,24 @@ final class ReadingPositionStore {
|
|||||||
defaults.set(dict, forKey: key(materialId))
|
defaults.set(dict, forKey: key(materialId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func save(materialId: String, checkpoint: ReadingCheckpoint) {
|
||||||
|
defaults.set(checkpointToDict(checkpoint), forKey: key(materialId))
|
||||||
|
}
|
||||||
|
|
||||||
/// Load the last reading position, if any.
|
/// Load the last reading position, if any.
|
||||||
func load(materialId: String) -> ReadingPosition? {
|
func load(materialId: String) -> ReadingPosition? {
|
||||||
guard let dict = defaults.dictionary(forKey: key(materialId)) else { return nil }
|
guard let dict = defaults.dictionary(forKey: key(materialId)) else { return nil }
|
||||||
|
if let checkpoint = checkpointFromDict(dict) {
|
||||||
|
return checkpoint.position
|
||||||
|
}
|
||||||
return positionFromDict(dict)
|
return positionFromDict(dict)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadCheckpoint(materialId: String) -> ReadingCheckpoint? {
|
||||||
|
guard let dict = defaults.dictionary(forKey: key(materialId)) else { return nil }
|
||||||
|
return checkpointFromDict(dict)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove saved position (e.g. when material is deleted).
|
/// Remove saved position (e.g. when material is deleted).
|
||||||
func remove(materialId: String) {
|
func remove(materialId: String) {
|
||||||
defaults.removeObject(forKey: key(materialId))
|
defaults.removeObject(forKey: key(materialId))
|
||||||
@ -45,6 +57,8 @@ final class ReadingPositionStore {
|
|||||||
|
|
||||||
private func positionToDict(_ pos: ReadingPosition) -> [String: Any] {
|
private func positionToDict(_ pos: ReadingPosition) -> [String: Any] {
|
||||||
switch pos {
|
switch pos {
|
||||||
|
case .block(let blockId, let blockProgress, let overallProgress):
|
||||||
|
return ["t": "block", "b": blockId, "bp": blockProgress, "op": overallProgress]
|
||||||
case .markdown(let blockId, let scrollProgress):
|
case .markdown(let blockId, let scrollProgress):
|
||||||
return ["t": "markdown", "b": blockId, "s": scrollProgress]
|
return ["t": "markdown", "b": blockId, "s": scrollProgress]
|
||||||
case .text(let lineNumber, let scrollProgress):
|
case .text(let lineNumber, let scrollProgress):
|
||||||
@ -53,6 +67,14 @@ final class ReadingPositionStore {
|
|||||||
return ["t": "pdf", "p": pageNumber, "pp": pageProgress, "op": overallProgress]
|
return ["t": "pdf", "p": pageNumber, "pp": pageProgress, "op": overallProgress]
|
||||||
case .image(let zoomScale, let offsetX, let offsetY):
|
case .image(let zoomScale, let offsetX, let offsetY):
|
||||||
return ["t": "image", "z": zoomScale, "x": offsetX, "y": offsetY]
|
return ["t": "image", "z": zoomScale, "x": offsetX, "y": offsetY]
|
||||||
|
case .pdfViewport(let pageNumber, let anchorX, let anchorY, let zoomScale, let overallProgress):
|
||||||
|
return ["t": "pdf_viewport", "p": pageNumber, "ax": anchorX, "ay": anchorY, "z": zoomScale, "op": overallProgress]
|
||||||
|
case .imageViewport(let zoomScale, let anchorX, let anchorY):
|
||||||
|
return ["t": "image_viewport", "z": zoomScale, "ax": anchorX, "ay": anchorY]
|
||||||
|
case .workbook(let sheetId, let topRow, let leftColumn, let selectedCell, let zoomScale, let overallProgress):
|
||||||
|
return ["t": "workbook", "sid": sheetId, "tr": topRow, "lc": leftColumn, "sc": selectedCell as Any, "z": zoomScale, "op": overallProgress as Any]
|
||||||
|
case .slide(let slideIndex, let zoomScale, let anchorX, let anchorY, let overallProgress):
|
||||||
|
return ["t": "slide", "si": slideIndex, "z": zoomScale, "ax": anchorX, "ay": anchorY, "op": overallProgress]
|
||||||
case .epub(let chapterId, let chapterProgress, let overallProgress):
|
case .epub(let chapterId, let chapterProgress, let overallProgress):
|
||||||
return ["t": "epub", "c": chapterId, "cp": chapterProgress, "op": overallProgress]
|
return ["t": "epub", "c": chapterId, "cp": chapterProgress, "op": overallProgress]
|
||||||
case .unknown:
|
case .unknown:
|
||||||
@ -63,6 +85,11 @@ final class ReadingPositionStore {
|
|||||||
private func positionFromDict(_ dict: [String: Any]) -> ReadingPosition? {
|
private func positionFromDict(_ dict: [String: Any]) -> ReadingPosition? {
|
||||||
guard let type = dict["t"] as? String else { return nil }
|
guard let type = dict["t"] as? String else { return nil }
|
||||||
switch type {
|
switch type {
|
||||||
|
case "block":
|
||||||
|
guard let b = dict["b"] as? String else { return nil }
|
||||||
|
let bp = (dict["bp"] as? Float) ?? 0
|
||||||
|
let op = (dict["op"] as? Float) ?? 0
|
||||||
|
return .block(blockId: b, blockProgress: bp, overallProgress: op)
|
||||||
case "markdown":
|
case "markdown":
|
||||||
guard let b = dict["b"] as? String else { return nil }
|
guard let b = dict["b"] as? String else { return nil }
|
||||||
let s = (dict["s"] as? Float) ?? 0
|
let s = (dict["s"] as? Float) ?? 0
|
||||||
@ -81,6 +108,33 @@ final class ReadingPositionStore {
|
|||||||
let x = (dict["x"] as? Float) ?? 0
|
let x = (dict["x"] as? Float) ?? 0
|
||||||
let y = (dict["y"] as? Float) ?? 0
|
let y = (dict["y"] as? Float) ?? 0
|
||||||
return .image(zoomScale: z, offsetX: x, offsetY: y)
|
return .image(zoomScale: z, offsetX: x, offsetY: y)
|
||||||
|
case "pdf_viewport":
|
||||||
|
let p = (dict["p"] as? UInt32) ?? 1
|
||||||
|
let ax = (dict["ax"] as? Float) ?? 0.5
|
||||||
|
let ay = (dict["ay"] as? Float) ?? 0
|
||||||
|
let z = (dict["z"] as? Float) ?? 1
|
||||||
|
let op = (dict["op"] as? Float) ?? 0
|
||||||
|
return .pdfViewport(pageNumber: p, anchorX: ax, anchorY: ay, zoomScale: z, overallProgress: op)
|
||||||
|
case "image_viewport":
|
||||||
|
let z = (dict["z"] as? Float) ?? 1
|
||||||
|
let ax = (dict["ax"] as? Float) ?? 0.5
|
||||||
|
let ay = (dict["ay"] as? Float) ?? 0.5
|
||||||
|
return .imageViewport(zoomScale: z, anchorX: ax, anchorY: ay)
|
||||||
|
case "workbook":
|
||||||
|
guard let sid = dict["sid"] as? String else { return nil }
|
||||||
|
let tr = (dict["tr"] as? UInt32) ?? 0
|
||||||
|
let lc = (dict["lc"] as? UInt32) ?? 0
|
||||||
|
let sc = dict["sc"] as? String
|
||||||
|
let z = (dict["z"] as? Float) ?? 1
|
||||||
|
let op = dict["op"] as? Float
|
||||||
|
return .workbook(sheetId: sid, topRow: tr, leftColumn: lc, selectedCell: sc, zoomScale: z, overallProgress: op)
|
||||||
|
case "slide":
|
||||||
|
let si = (dict["si"] as? UInt32) ?? 0
|
||||||
|
let z = (dict["z"] as? Float) ?? 1
|
||||||
|
let ax = (dict["ax"] as? Float) ?? 0.5
|
||||||
|
let ay = (dict["ay"] as? Float) ?? 0.5
|
||||||
|
let op = (dict["op"] as? Float) ?? 0
|
||||||
|
return .slide(slideIndex: si, zoomScale: z, anchorX: ax, anchorY: ay, overallProgress: op)
|
||||||
case "epub":
|
case "epub":
|
||||||
guard let c = dict["c"] as? String else { return nil }
|
guard let c = dict["c"] as? String else { return nil }
|
||||||
let cp = (dict["cp"] as? Float) ?? 0
|
let cp = (dict["cp"] as? Float) ?? 0
|
||||||
@ -90,4 +144,70 @@ final class ReadingPositionStore {
|
|||||||
return .unknown
|
return .unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func checkpointToDict(_ checkpoint: ReadingCheckpoint) -> [String: Any] {
|
||||||
|
[
|
||||||
|
"schemaVersion": checkpoint.schemaVersion,
|
||||||
|
"materialId": checkpoint.materialId,
|
||||||
|
"representationFingerprint": checkpoint.representationFingerprint,
|
||||||
|
"rendererKind": rendererKindRaw(checkpoint.rendererKind),
|
||||||
|
"capturedAtMs": checkpoint.capturedAtMs,
|
||||||
|
"deviceId": checkpoint.deviceId,
|
||||||
|
"deviceSequence": checkpoint.deviceSequence,
|
||||||
|
"position": positionToDict(checkpoint.position)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkpointFromDict(_ dict: [String: Any]) -> ReadingCheckpoint? {
|
||||||
|
guard
|
||||||
|
let schemaVersion = dict["schemaVersion"] as? UInt32,
|
||||||
|
let materialId = dict["materialId"] as? String,
|
||||||
|
let representationFingerprint = dict["representationFingerprint"] as? String,
|
||||||
|
let rendererKindRaw = dict["rendererKind"] as? String,
|
||||||
|
let capturedAtMs = dict["capturedAtMs"] as? UInt64,
|
||||||
|
let deviceId = dict["deviceId"] as? String,
|
||||||
|
let deviceSequence = dict["deviceSequence"] as? UInt64,
|
||||||
|
let positionDict = dict["position"] as? [String: Any],
|
||||||
|
let position = positionFromDict(positionDict),
|
||||||
|
let rendererKind = rendererKind(from: rendererKindRaw)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReadingCheckpoint(
|
||||||
|
schemaVersion: schemaVersion,
|
||||||
|
materialId: materialId,
|
||||||
|
representationFingerprint: representationFingerprint,
|
||||||
|
rendererKind: rendererKind,
|
||||||
|
capturedAtMs: capturedAtMs,
|
||||||
|
deviceId: deviceId,
|
||||||
|
deviceSequence: deviceSequence,
|
||||||
|
position: position
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rendererKindRaw(_ kind: RendererKind) -> String {
|
||||||
|
switch kind {
|
||||||
|
case .block: return "block"
|
||||||
|
case .pdf: return "pdf"
|
||||||
|
case .image: return "image"
|
||||||
|
case .workbook: return "workbook"
|
||||||
|
case .slide: return "slide"
|
||||||
|
case .epub: return "epub"
|
||||||
|
case .unknown: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rendererKind(from raw: String) -> RendererKind? {
|
||||||
|
switch raw {
|
||||||
|
case "block": return .block
|
||||||
|
case "pdf": return .pdf
|
||||||
|
case "image": return .image
|
||||||
|
case "workbook": return .workbook
|
||||||
|
case "slide": return .slide
|
||||||
|
case "epub": return .epub
|
||||||
|
case "unknown": return .unknown
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ final class ReadingRuntimeSessionManager {
|
|||||||
private(set) var activeSessionId: String?
|
private(set) var activeSessionId: String?
|
||||||
private(set) var activeContext: ReadingMaterialContext?
|
private(set) var activeContext: ReadingMaterialContext?
|
||||||
private(set) var lastPosition: ReadingPosition?
|
private(set) var lastPosition: ReadingPosition?
|
||||||
|
private(set) var lastCheckpoint: ReadingCheckpoint?
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
@ -50,9 +51,16 @@ final class ReadingRuntimeSessionManager {
|
|||||||
private var positionDebounceTask: Task<Void, Never>?
|
private var positionDebounceTask: Task<Void, Never>?
|
||||||
private var lastHeartbeatAtMs: Int64 = 0
|
private var lastHeartbeatAtMs: Int64 = 0
|
||||||
private var backgroundedAtMs: Int64 = 0
|
private var backgroundedAtMs: Int64 = 0
|
||||||
|
private var activeRendererKind: RendererKind = .unknown
|
||||||
|
private var activeRepresentationFingerprint = ""
|
||||||
|
private let positionStore = ReadingPositionStore.shared
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
/// Max allowed background time before session is considered stale (5 minutes).
|
/// Max allowed background time before session is considered stale (5 minutes).
|
||||||
private let sessionResumeMaxAgeMs: Int64 = 300_000
|
private let sessionResumeMaxAgeMs: Int64 = 300_000
|
||||||
|
private let checkpointSchemaVersion: UInt32 = 2
|
||||||
|
private let deviceIdKey = "reading_checkpoint.device_id"
|
||||||
|
private let deviceSequenceKey = "reading_checkpoint.device_sequence"
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
@ -66,23 +74,41 @@ final class ReadingRuntimeSessionManager {
|
|||||||
// MARK: - Session Lifecycle
|
// MARK: - Session Lifecycle
|
||||||
|
|
||||||
/// Start a reading session for a material.
|
/// Start a reading session for a material.
|
||||||
/// - Throws if a session is already active (must close first).
|
/// If a session is already active for the same material, reuses it.
|
||||||
|
/// If active for a different material, closes the old one first.
|
||||||
func openMaterial(_ context: ReadingMaterialContext) throws {
|
func openMaterial(_ context: ReadingMaterialContext) throws {
|
||||||
guard state == .idle || state == .closed else {
|
if state == .active || state == .paused {
|
||||||
throw SessionError.alreadyActive
|
if activeContext?.materialId == context.materialId {
|
||||||
|
return // same material, reuse existing session
|
||||||
|
}
|
||||||
|
closeMaterial() // different material, close old session first
|
||||||
}
|
}
|
||||||
|
|
||||||
state = .opening
|
state = .opening
|
||||||
let material = ReadingMaterialRef(materialId: context.materialId)
|
|
||||||
let now = nowMs()
|
let now = nowMs()
|
||||||
|
|
||||||
|
// Build V2 material ref with full file metadata
|
||||||
|
let materialV2 = ReadingMaterialRefV2(
|
||||||
|
materialId: context.materialId,
|
||||||
|
filePath: context.filePath,
|
||||||
|
fileName: context.fileName,
|
||||||
|
fileType: context.fileType,
|
||||||
|
mimeType: context.mimeType,
|
||||||
|
fileSize: context.fileSize,
|
||||||
|
contentHash: nil,
|
||||||
|
createdAtMs: nil
|
||||||
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let sessionId = try adapter.startSession(material: material, timestampMs: now)
|
activeRendererKind = rendererKind(for: context)
|
||||||
|
activeRepresentationFingerprint = try buildRepresentationFingerprint(for: context, rendererKind: activeRendererKind)
|
||||||
|
let sessionId = try adapter.startSession(material: materialV2, timestampMs: now)
|
||||||
activeSessionId = sessionId
|
activeSessionId = sessionId
|
||||||
activeContext = context
|
activeContext = context
|
||||||
state = .active
|
state = .active
|
||||||
ReadingContextRegistry.shared.register(context)
|
ReadingContextRegistry.shared.register(context)
|
||||||
lastPosition = nil
|
lastPosition = nil
|
||||||
|
lastCheckpoint = nil
|
||||||
lastHeartbeatAtMs = now
|
lastHeartbeatAtMs = now
|
||||||
|
|
||||||
// Push MaterialOpened
|
// Push MaterialOpened
|
||||||
@ -113,10 +139,11 @@ final class ReadingRuntimeSessionManager {
|
|||||||
let now = nowMs()
|
let now = nowMs()
|
||||||
let sinceLastTick = lastHeartbeatAtMs > 0 ? UInt32((now - lastHeartbeatAtMs) / 1000) : 0
|
let sinceLastTick = lastHeartbeatAtMs > 0 ? UInt32((now - lastHeartbeatAtMs) / 1000) : 0
|
||||||
_ = try? adapter.pushClosed(sessionId: sessionId, materialId: ctx.materialId, delta: sinceLastTick, timestampMs: now)
|
_ = try? adapter.pushClosed(sessionId: sessionId, materialId: ctx.materialId, delta: sinceLastTick, timestampMs: now)
|
||||||
_ = try? adapter.closeSession(sessionId)
|
_ = try? adapter.closeSession(sessionId, timestampMs: now)
|
||||||
|
|
||||||
activeSessionId = nil
|
activeSessionId = nil
|
||||||
lastPosition = nil
|
lastPosition = nil
|
||||||
|
lastCheckpoint = nil
|
||||||
state = .closed
|
state = .closed
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,15 +185,17 @@ final class ReadingRuntimeSessionManager {
|
|||||||
func updatePosition(_ position: ReadingPosition) {
|
func updatePosition(_ position: ReadingPosition) {
|
||||||
guard state == .active, let sessionId = activeSessionId, let ctx = activeContext else { return }
|
guard state == .active, let sessionId = activeSessionId, let ctx = activeContext else { return }
|
||||||
lastPosition = position
|
lastPosition = position
|
||||||
|
lastCheckpoint = makeCheckpoint(position: position, context: ctx)
|
||||||
|
|
||||||
positionDebounceTask?.cancel()
|
positionDebounceTask?.cancel()
|
||||||
positionDebounceTask = Task { [weak self] in
|
positionDebounceTask = Task { [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
guard let self, !Task.isCancelled, let pos = self.lastPosition,
|
guard let self, !Task.isCancelled, let checkpoint = self.lastCheckpoint,
|
||||||
let sid = self.activeSessionId, let c = self.activeContext else { return }
|
let sid = self.activeSessionId, let c = self.activeContext else { return }
|
||||||
_ = try? self.adapter.pushPositionChanged(
|
self.positionStore.save(materialId: c.materialId, checkpoint: checkpoint)
|
||||||
|
_ = try? self.adapter.pushCheckpointChanged(
|
||||||
sessionId: sid, materialId: c.materialId,
|
sessionId: sid, materialId: c.materialId,
|
||||||
position: pos, timestampMs: self.nowMs()
|
checkpoint: checkpoint, timestampMs: self.nowMs()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,6 +205,12 @@ final class ReadingRuntimeSessionManager {
|
|||||||
_ = try? adapter.pushMarkedAsRead(sessionId: sessionId, materialId: ctx.materialId, timestampMs: nowMs())
|
_ = try? adapter.pushMarkedAsRead(sessionId: sessionId, materialId: ctx.materialId, timestampMs: nowMs())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCompatibleRestoreCheckpoint(_ checkpoint: ReadingCheckpoint) -> Bool {
|
||||||
|
checkpoint.materialId == activeContext?.materialId &&
|
||||||
|
checkpoint.rendererKind == activeRendererKind &&
|
||||||
|
checkpoint.representationFingerprint == activeRepresentationFingerprint
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Heartbeat
|
// MARK: - Heartbeat
|
||||||
|
|
||||||
private func startHeartbeat(interval: TimeInterval = 15) {
|
private func startHeartbeat(interval: TimeInterval = 15) {
|
||||||
@ -187,9 +222,9 @@ final class ReadingRuntimeSessionManager {
|
|||||||
let sid = self.activeSessionId,
|
let sid = self.activeSessionId,
|
||||||
let ctx = self.activeContext else { return }
|
let ctx = self.activeContext else { return }
|
||||||
let now = self.nowMs()
|
let now = self.nowMs()
|
||||||
_ = try? self.adapter.pushHeartbeat(
|
_ = try? self.adapter.pushHeartbeatCheckpoint(
|
||||||
sessionId: sid, materialId: ctx.materialId,
|
sessionId: sid, materialId: ctx.materialId,
|
||||||
delta: UInt32(interval), position: self.lastPosition,
|
delta: UInt32(interval), checkpoint: self.lastCheckpoint,
|
||||||
timestampMs: now
|
timestampMs: now
|
||||||
)
|
)
|
||||||
self.lastHeartbeatAtMs = now
|
self.lastHeartbeatAtMs = now
|
||||||
@ -205,10 +240,11 @@ final class ReadingRuntimeSessionManager {
|
|||||||
private func flushPosition() {
|
private func flushPosition() {
|
||||||
positionDebounceTask?.cancel()
|
positionDebounceTask?.cancel()
|
||||||
// Force-send last position before clearing
|
// Force-send last position before clearing
|
||||||
if let pos = lastPosition, let sid = activeSessionId, let ctx = activeContext {
|
if let checkpoint = lastCheckpoint, let sid = activeSessionId, let ctx = activeContext {
|
||||||
_ = try? adapter.pushPositionChanged(
|
positionStore.save(materialId: ctx.materialId, checkpoint: checkpoint)
|
||||||
|
_ = try? adapter.pushCheckpointChanged(
|
||||||
sessionId: sid, materialId: ctx.materialId,
|
sessionId: sid, materialId: ctx.materialId,
|
||||||
position: pos, timestampMs: nowMs()
|
checkpoint: checkpoint, timestampMs: nowMs()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
positionDebounceTask = nil
|
positionDebounceTask = nil
|
||||||
@ -219,6 +255,88 @@ final class ReadingRuntimeSessionManager {
|
|||||||
private func nowMs() -> Int64 {
|
private func nowMs() -> Int64 {
|
||||||
Int64(Date().timeIntervalSince1970 * 1000)
|
Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeCheckpoint(position: ReadingPosition, context: ReadingMaterialContext) -> ReadingCheckpoint {
|
||||||
|
let normalizedPosition = ReadingPositionAdapter.checkpointPosition(from: position)
|
||||||
|
let capturedAtMs = UInt64(max(0, nowMs()))
|
||||||
|
let sequence = nextDeviceSequence()
|
||||||
|
return ReadingCheckpoint(
|
||||||
|
schemaVersion: checkpointSchemaVersion,
|
||||||
|
materialId: context.materialId,
|
||||||
|
representationFingerprint: activeRepresentationFingerprint,
|
||||||
|
rendererKind: activeRendererKind,
|
||||||
|
capturedAtMs: capturedAtMs,
|
||||||
|
deviceId: deviceId(),
|
||||||
|
deviceSequence: sequence,
|
||||||
|
position: normalizedPosition
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildRepresentationFingerprint(for context: ReadingMaterialContext, rendererKind: RendererKind) throws -> String {
|
||||||
|
guard !context.filePath.isEmpty else {
|
||||||
|
return "\(context.materialId):\(rendererKind)"
|
||||||
|
}
|
||||||
|
let sourceFingerprint = try adapter.runtimeComputeFileFingerprint(filePath: context.filePath)
|
||||||
|
return adapter.runtimeBuildRepresentationFingerprint(
|
||||||
|
sourceFingerprint: sourceFingerprint,
|
||||||
|
rendererKind: rendererKind,
|
||||||
|
parserVersion: parserVersion(for: rendererKind),
|
||||||
|
schemaVersion: checkpointSchemaVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rendererKind(for context: ReadingMaterialContext) -> RendererKind {
|
||||||
|
switch context.fileType.lowercased() {
|
||||||
|
case "markdown", "text", "word":
|
||||||
|
return .block
|
||||||
|
case "pdf":
|
||||||
|
return .pdf
|
||||||
|
case "image":
|
||||||
|
return .image
|
||||||
|
case "excel":
|
||||||
|
return .workbook
|
||||||
|
case "powerpoint":
|
||||||
|
return .slide
|
||||||
|
case "epub":
|
||||||
|
return .epub
|
||||||
|
default:
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parserVersion(for rendererKind: RendererKind) -> String {
|
||||||
|
switch rendererKind {
|
||||||
|
case .block:
|
||||||
|
return "block-parser-v1"
|
||||||
|
case .pdf:
|
||||||
|
return "pdf-locator-v1"
|
||||||
|
case .image:
|
||||||
|
return "image-locator-v1"
|
||||||
|
case .workbook:
|
||||||
|
return "workbook-parser-v1"
|
||||||
|
case .slide:
|
||||||
|
return "slide-parser-v1"
|
||||||
|
case .epub:
|
||||||
|
return "epub-locator-v1"
|
||||||
|
case .unknown:
|
||||||
|
return "unknown-v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deviceId() -> String {
|
||||||
|
if let existing = defaults.string(forKey: deviceIdKey) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let generated = UUID().uuidString
|
||||||
|
defaults.set(generated, forKey: deviceIdKey)
|
||||||
|
return generated
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nextDeviceSequence() -> UInt64 {
|
||||||
|
let next = UInt64(defaults.integer(forKey: deviceSequenceKey)) + 1
|
||||||
|
defaults.set(Int(next), forKey: deviceSequenceKey)
|
||||||
|
return next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|||||||
@ -18,7 +18,7 @@ struct QuizListView: View {
|
|||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image("icon-question").font(.system(size: 40)).foregroundColor(Color.zxF03)
|
Image("icon-question").font(.system(size: 40)).foregroundColor(Color.zxF03)
|
||||||
Text("暂无测验").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF04)
|
Text("暂无测验").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF04)
|
||||||
Text("基于知识库内容自动生成测验题目").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
Text("基于资料内容 AI 生成测验题目").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
||||||
Button {
|
Button {
|
||||||
Task { await generateQuiz() }
|
Task { await generateQuiz() }
|
||||||
} label: {
|
} label: {
|
||||||
@ -125,8 +125,8 @@ struct QuizTakerView: View {
|
|||||||
Text(question.stem ?? "").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).lineSpacing(4)
|
Text(question.stem ?? "").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).lineSpacing(4)
|
||||||
|
|
||||||
// M-QUIZ-01-CLEANUP-02: 使用 QuizQuestionType 枚举替换裸字符串
|
// M-QUIZ-01-CLEANUP-02: 使用 QuizQuestionType 枚举替换裸字符串
|
||||||
switch question.questionType {
|
switch question.questionType ?? "" {
|
||||||
case .choice:
|
case "choice":
|
||||||
if let options = question.options {
|
if let options = question.options {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
ForEach(Array(options.enumerated()), id: \.offset) { i, opt in
|
ForEach(Array(options.enumerated()), id: \.offset) { i, opt in
|
||||||
@ -142,7 +142,7 @@ struct QuizTakerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .judge:
|
case "judge":
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ForEach(["true", "false"], id: \.self) { v in
|
ForEach(["true", "false"], id: \.self) { v in
|
||||||
Button {
|
Button {
|
||||||
@ -152,9 +152,9 @@ struct QuizTakerView: View {
|
|||||||
}.foregroundColor(.primary)
|
}.foregroundColor(.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .fill:
|
case "fill":
|
||||||
TextField("输入你的答案", text: Binding(get: { answers[question.id] ?? "" }, set: { setAnswer(question.id, $0) })).font(.system(size: 14)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 48).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
|
TextField("输入你的答案", text: Binding(get: { answers[question.id] ?? "" }, set: { setAnswer(question.id, $0) })).font(.system(size: 14)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 48).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
case nil:
|
default:
|
||||||
// M-QUIZ-01-CLEANUP-02: 未知题型 fail-closed
|
// M-QUIZ-01-CLEANUP-02: 未知题型 fail-closed
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: "exclamationmark.triangle").font(.system(size: 24)).foregroundColor(Color.zxCoral)
|
Image(systemName: "exclamationmark.triangle").font(.system(size: 24)).foregroundColor(Color.zxCoral)
|
||||||
@ -271,36 +271,8 @@ struct QuizResultView: View {
|
|||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("答题详情").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
|
Text("答题详情").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
ForEach(r.answers ?? []) { a in
|
ForEach(r.answers ?? []) { a in
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
AnswerDetailRow(a: a)
|
||||||
HStack(spacing: 12) {
|
.padding(12).background(a.isCorrect == true ? Color.zxGreen.opacity(0.05) : Color.zxCoral.opacity(0.05)).clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
Image(systemName: a.isCorrect == true ? "checkmark.circle" : "xmark.circle").font(.system(size: 18)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
|
|
||||||
Text(a.question?.stem ?? "").font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(3)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
// M-QUIZ-01-03 / M-QUIZ-01-CLEANUP-02: 使用 QuizQuestionType 展示答案
|
|
||||||
let qType = a.question?.questionType
|
|
||||||
let options = a.question?.options ?? []
|
|
||||||
let displayUserAnswer = Self.answerDisplayText(raw: a.userAnswer ?? "", type: qType, options: options)
|
|
||||||
let displayCorrectAnswer = Self.answerDisplayText(raw: a.question?.answer ?? "", type: qType, options: options)
|
|
||||||
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
if !displayUserAnswer.isEmpty {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("你的答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
|
|
||||||
Text(displayUserAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !displayCorrectAnswer.isEmpty {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("正确答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
|
|
||||||
Text(displayCorrectAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxGreen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let exp = a.question?.explanation {
|
|
||||||
Text(exp).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(4)
|
|
||||||
}
|
|
||||||
}.padding(12).background(a.isCorrect == true ? Color.zxGreen.opacity(0.05) : Color.zxCoral.opacity(0.05)).clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
||||||
@ -318,6 +290,47 @@ struct QuizResultView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// M-QUIZ-01-CLEANUP-02: 使用 QuizQuestionType.displayAnswer 统一展示
|
// M-QUIZ-01-CLEANUP-02: 使用 QuizQuestionType.displayAnswer 统一展示
|
||||||
|
fileprivate struct AnswerDetailRow: View {
|
||||||
|
let a: QuizAttemptAnswer
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
let correct = a.isCorrect == true
|
||||||
|
let stem = a.question?.stem ?? ""
|
||||||
|
Image(systemName: correct ? "checkmark.circle" : "xmark.circle").font(.system(size: 18)).foregroundColor(correct ? Color.zxGreen : Color.zxCoral)
|
||||||
|
Text(stem).font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(3)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
let qType = a.question?.questionType
|
||||||
|
let options = a.question?.options ?? []
|
||||||
|
let displayUserAnswer = Self.answerDisplayText(raw: a.userAnswer ?? "", type: qType, options: options)
|
||||||
|
let displayCorrectAnswer = Self.answerDisplayText(raw: a.question?.answer ?? "", type: qType, options: options)
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
if !displayUserAnswer.isEmpty {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("你的答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||||
|
Text(displayUserAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !displayCorrectAnswer.isEmpty {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("正确答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||||
|
Text(displayCorrectAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxGreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let exp = a.question?.explanation {
|
||||||
|
Text(exp).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func answerDisplayText(raw: String, type: String?, options: [String]) -> String {
|
||||||
|
guard let t = type, let qType = QuizQuestionType(rawValue: t) else { return raw }
|
||||||
|
return qType.displayAnswer(raw: raw, options: options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func answerDisplayText(raw: String, type: QuizQuestionType?, options: [String]) -> String {
|
static func answerDisplayText(raw: String, type: QuizQuestionType?, options: [String]) -> String {
|
||||||
guard let qType = type else { return raw } // 未知题型保留原始值
|
guard let qType = type else { return raw } // 未知题型保留原始值
|
||||||
return qType.displayAnswer(raw: raw, options: options)
|
return qType.displayAnswer(raw: raw, options: options)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ struct StudyHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await vm.loadAll() }
|
.task { await vm.loadAll() }
|
||||||
|
.onAppear { Task { await vm.refresh() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Loading / Error
|
// MARK: - Loading / Error
|
||||||
@ -121,14 +122,14 @@ struct StudyHomeView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var mainActionCard: some View {
|
private var mainActionCard: some View {
|
||||||
switch vm.mainAction {
|
switch vm.mainAction {
|
||||||
case .continueSession(let materialId, let filePath, let materialType, let kbTitle, let elapsed, _):
|
case .continueSession(let materialId, let filePath, let materialType, let kbTitle, let elapsed, let resumePosition):
|
||||||
actionCard(
|
actionCard(
|
||||||
title: "继续上次学习",
|
title: "继续上次学习",
|
||||||
subtitle: "《\(kbTitle)》· \(elapsed)",
|
subtitle: "《\(kbTitle)》· \(elapsed)",
|
||||||
icon: "arrow.triangle.2.circlepath",
|
icon: "arrow.triangle.2.circlepath",
|
||||||
color: Color.zxPurple,
|
color: Color.zxPurple,
|
||||||
cta: "继续学习",
|
cta: "继续学习",
|
||||||
route: .materialReader(materialId: materialId, filePath: filePath, materialType: materialType, title: kbTitle)
|
route: .materialReader(materialId: materialId, filePath: filePath, materialType: materialType, title: kbTitle, resumePosition: resumePosition)
|
||||||
)
|
)
|
||||||
case .todaysReview(let count, let minutes):
|
case .todaysReview(let count, let minutes):
|
||||||
actionCard(
|
actionCard(
|
||||||
|
|||||||
@ -43,11 +43,29 @@ extension ReadingAPIService: ContinueLearningServicing {}
|
|||||||
// MARK: - Action states
|
// MARK: - Action states
|
||||||
|
|
||||||
enum MainAction: Equatable {
|
enum MainAction: Equatable {
|
||||||
case continueSession(materialId: String, filePath: String, materialType: MaterialType, kbTitle: String, elapsed: String, resumePosition: ReadingPosition?)
|
case continueSession(materialId: String, filePath: String, materialType: MaterialType, kbTitle: String, elapsed: String, resumePosition: ReadingPositionValue?)
|
||||||
case todaysReview(count: Int, estimatedMinutes: Int)
|
case todaysReview(count: Int, estimatedMinutes: Int)
|
||||||
case selfTest(quizId: String, count: Int)
|
case selfTest(quizId: String, count: Int)
|
||||||
case startLearning(knowledgeBaseId: String, kbCount: Int)
|
case startLearning(knowledgeBaseId: String, kbCount: Int)
|
||||||
case empty
|
case empty
|
||||||
|
|
||||||
|
static func == (lhs: MainAction, rhs: MainAction) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.continueSession(let lmid, let lpath, let lmt, let lkb, let lel, let lpos),
|
||||||
|
.continueSession(let rmid, let rpath, let rmt, let rkb, let rel, let rpos)):
|
||||||
|
return lmid == rmid && lpath == rpath && lmt == rmt && lkb == rkb && lel == rel && lpos == rpos
|
||||||
|
case (.todaysReview(let lc, let lm), .todaysReview(let rc, let rm)):
|
||||||
|
return lc == rc && lm == rm
|
||||||
|
case (.selfTest(let lq, let lc), .selfTest(let rq, let rc)):
|
||||||
|
return lq == rq && lc == rc
|
||||||
|
case (.startLearning(let lkb, let lc), .startLearning(let rkb, let rc)):
|
||||||
|
return lkb == rkb && lc == rc
|
||||||
|
case (.empty, .empty):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HomeLoadingState: Equatable {
|
enum HomeLoadingState: Equatable {
|
||||||
@ -180,7 +198,7 @@ final class StudyHomeViewModel: ObservableObject {
|
|||||||
let resolved = MaterialPathResolver.resolve(materialId: materialId, apiType: cl.type, title: cl.title) {
|
let resolved = MaterialPathResolver.resolve(materialId: materialId, apiType: cl.type, title: cl.title) {
|
||||||
let kbTitle = cl.title ?? knowledgeBases.first?.title ?? "学习"
|
let kbTitle = cl.title ?? knowledgeBases.first?.title ?? "学习"
|
||||||
let elapsed = formatElapsedSince(cl.lastReadAt)
|
let elapsed = formatElapsedSince(cl.lastReadAt)
|
||||||
return .continueSession(materialId: materialId, filePath: resolved.filePath, materialType: resolved.materialType, kbTitle: kbTitle, elapsed: elapsed, resumePosition: nil)
|
return .continueSession(materialId: materialId, filePath: resolved.filePath, materialType: resolved.materialType, kbTitle: kbTitle, elapsed: elapsed, resumePosition: cl.lastPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dueCards.isEmpty {
|
if !dueCards.isEmpty {
|
||||||
|
|||||||
@ -0,0 +1,135 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import AIStudyApp
|
||||||
|
|
||||||
|
final class ReadingPositionAdapterTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeValue(
|
||||||
|
type: String?,
|
||||||
|
blockId: String? = nil,
|
||||||
|
blockProgress: Float? = nil,
|
||||||
|
lineNumber: UInt32? = nil,
|
||||||
|
pageNumber: UInt32? = nil,
|
||||||
|
scrollProgress: Float? = nil,
|
||||||
|
pageProgress: Float? = nil,
|
||||||
|
overallProgress: Float? = nil,
|
||||||
|
zoomScale: Float? = nil,
|
||||||
|
offsetX: Float? = nil,
|
||||||
|
offsetY: Float? = nil,
|
||||||
|
chapterId: String? = nil,
|
||||||
|
chapterProgress: Float? = nil
|
||||||
|
) -> ReadingPositionValue {
|
||||||
|
ReadingPositionValue(
|
||||||
|
type: type,
|
||||||
|
pageNumber: pageNumber,
|
||||||
|
blockId: blockId,
|
||||||
|
blockProgress: blockProgress,
|
||||||
|
scrollProgress: scrollProgress,
|
||||||
|
lineNumber: lineNumber,
|
||||||
|
pageProgress: pageProgress,
|
||||||
|
overallProgress: overallProgress,
|
||||||
|
zoomScale: zoomScale,
|
||||||
|
offsetX: offsetX,
|
||||||
|
offsetY: offsetY,
|
||||||
|
chapterId: chapterId,
|
||||||
|
chapterProgress: chapterProgress,
|
||||||
|
offset: nil,
|
||||||
|
percent: nil,
|
||||||
|
section: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_fromValue_markdown_with_blockId() {
|
||||||
|
let v = makeValue(type: "Markdown", blockId: "b1", scrollProgress: 0.5)
|
||||||
|
let pos = ReadingPositionAdapter.fromValue(v)
|
||||||
|
guard case let .markdown(blockId, scrollProgress) = pos else {
|
||||||
|
return XCTFail("Expected .markdown, got \(String(describing: pos))")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(blockId, "b1")
|
||||||
|
XCTAssertEqual(scrollProgress, 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_fromValue_markdown_nil_blockId_returns_nil() {
|
||||||
|
let v = makeValue(type: "Markdown")
|
||||||
|
XCTAssertNil(ReadingPositionAdapter.fromValue(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_fromValue_text_with_optional_fields() {
|
||||||
|
let v = makeValue(type: "Text", lineNumber: 42, scrollProgress: 0.8)
|
||||||
|
guard case let .text(lineNumber, scrollProgress) = ReadingPositionAdapter.fromValue(v) else {
|
||||||
|
return XCTFail("Expected .text")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(lineNumber, 42)
|
||||||
|
XCTAssertEqual(scrollProgress, 0.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_fromValue_pdf_with_all_fields() {
|
||||||
|
let v = makeValue(type: "Pdf", pageNumber: 5, pageProgress: 0.3, overallProgress: 0.15)
|
||||||
|
guard case let .pdf(pageNumber, pageProgress, overallProgress) = ReadingPositionAdapter.fromValue(v) else {
|
||||||
|
return XCTFail("Expected .pdf")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(pageNumber, 5)
|
||||||
|
XCTAssertEqual(pageProgress, 0.3)
|
||||||
|
XCTAssertEqual(overallProgress, 0.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_fromBlockScroll_markdown_returns_position() {
|
||||||
|
let blocks: [DocumentBlock] = [
|
||||||
|
.heading(id: "h1", level: 1, text: "Title"),
|
||||||
|
.paragraph(id: "p1", text: "Content"),
|
||||||
|
]
|
||||||
|
let pos = ReadingPositionAdapter.fromBlockScroll(
|
||||||
|
materialType: .markdown, blocks: blocks, scrollProgress: 0.5, blockIndex: 0
|
||||||
|
)
|
||||||
|
guard case .block(let blockId, let blockProgress, let overallProgress) = pos else {
|
||||||
|
return XCTFail("Expected .block")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(blockId, "h1")
|
||||||
|
XCTAssertEqual(blockProgress, 0.5)
|
||||||
|
XCTAssertEqual(overallProgress, 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_blockIdForRestore_prefers_existing_blockId() {
|
||||||
|
let blocks: [DocumentBlock] = [
|
||||||
|
.heading(id: "h1", level: 1, text: "Title"),
|
||||||
|
.paragraph(id: "p1", text: "Content"),
|
||||||
|
.paragraph(id: "p2", text: "Tail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let restored = ReadingPositionAdapter.blockIdForRestore(
|
||||||
|
from: .block(blockId: "p1", blockProgress: 0.25, overallProgress: 0.8),
|
||||||
|
in: blocks
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(restored, "p1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_blockIdForRestore_fallsBack_to_progress_when_blockId_missing() {
|
||||||
|
let blocks: [DocumentBlock] = [
|
||||||
|
.heading(id: "h1", level: 1, text: "Title"),
|
||||||
|
.paragraph(id: "p1", text: "Content"),
|
||||||
|
.paragraph(id: "p2", text: "Tail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let restored = ReadingPositionAdapter.blockIdForRestore(
|
||||||
|
from: .block(blockId: "missing", blockProgress: 0.1, overallProgress: 0.9),
|
||||||
|
in: blocks
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(restored, "p2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_blockIdForRestore_text_out_of_range_fallsBack_to_scroll_progress() {
|
||||||
|
let blocks: [DocumentBlock] = [
|
||||||
|
.heading(id: "h1", level: 1, text: "Title"),
|
||||||
|
.paragraph(id: "p1", text: "Content"),
|
||||||
|
.paragraph(id: "p2", text: "Tail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let restored = ReadingPositionAdapter.blockIdForRestore(
|
||||||
|
from: .text(lineNumber: 99, scrollProgress: 0.45),
|
||||||
|
in: blocks
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(restored, "p1")
|
||||||
|
}
|
||||||
|
}
|
||||||
189
AIStudyApp/AIStudyAppReaderTests/SessionManagerTests.swift
Normal file
189
AIStudyApp/AIStudyAppReaderTests/SessionManagerTests.swift
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import AIStudyApp
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class SpyReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
||||||
|
var fingerprint = "fingerprint-1"
|
||||||
|
private var nextSequence: UInt64 = 1
|
||||||
|
|
||||||
|
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
|
||||||
|
"spy-session"
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseSession(_ sessionId: String) throws {}
|
||||||
|
func resumeSession(_ sessionId: String) throws {}
|
||||||
|
func closeSession(_ sessionId: String, timestampMs: Int64) throws {}
|
||||||
|
|
||||||
|
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialOpened)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushClosed(sessionId: String, materialId: String, delta: UInt32, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialClosed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushPositionChanged(sessionId: String, materialId: String, position: ReadingPosition, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, position: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, checkpoint: checkpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, position: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, checkpoint: checkpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .markedAsRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportEvents(limit: UInt32, timestampMs: Int64) -> [ReadingEventV2] { [] }
|
||||||
|
func ackEvents(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) }
|
||||||
|
func markFailed(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) }
|
||||||
|
func reloadStaleEvents() -> UInt32 { 0 }
|
||||||
|
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { 0 }
|
||||||
|
|
||||||
|
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
|
||||||
|
fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
|
||||||
|
"\(sourceFingerprint)|\(rendererKind)|\(parserVersion)|\(schemaVersion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeEvent(
|
||||||
|
sessionId: String,
|
||||||
|
materialId: String,
|
||||||
|
eventType: ReadingEventTypeV2,
|
||||||
|
position: ReadingPosition? = nil,
|
||||||
|
checkpoint: ReadingCheckpoint? = nil
|
||||||
|
) -> ReadingEventV2 {
|
||||||
|
defer { nextSequence += 1 }
|
||||||
|
return ReadingEventV2(
|
||||||
|
eventId: "evt-\(nextSequence)",
|
||||||
|
clientSessionId: sessionId,
|
||||||
|
materialId: materialId,
|
||||||
|
eventType: eventType,
|
||||||
|
position: position,
|
||||||
|
checkpoint: checkpoint,
|
||||||
|
activeSecondsDelta: 0,
|
||||||
|
timestampMs: 1_700_000_000_000,
|
||||||
|
sequence: nextSequence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SessionManagerTests: XCTestCase {
|
||||||
|
|
||||||
|
var manager: ReadingRuntimeSessionManager!
|
||||||
|
var noopAdapter: NoopReadingRuntimeAdapter!
|
||||||
|
fileprivate var spyAdapter: SpyReadingRuntimeAdapter!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
manager = ReadingRuntimeSessionManager.shared
|
||||||
|
noopAdapter = NoopReadingRuntimeAdapter()
|
||||||
|
spyAdapter = SpyReadingRuntimeAdapter()
|
||||||
|
manager.adapter = noopAdapter
|
||||||
|
manager.closeMaterial()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
manager.closeMaterial()
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_initial_state_is_idle() {
|
||||||
|
XCTAssertTrue(manager.state == .idle || manager.state == .closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_markAsRead_guards_against_inactive() {
|
||||||
|
manager.closeMaterial()
|
||||||
|
manager.markAsRead()
|
||||||
|
XCTAssertTrue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_isCompatibleRestoreCheckpoint_matches_active_representation() throws {
|
||||||
|
manager.adapter = spyAdapter
|
||||||
|
let context = ReadingMaterialContext(
|
||||||
|
readingTargetType: .knowledgeSource,
|
||||||
|
materialId: "restore-match",
|
||||||
|
materialType: .markdown,
|
||||||
|
filePath: "",
|
||||||
|
fileName: "doc.md",
|
||||||
|
fileType: "markdown"
|
||||||
|
)
|
||||||
|
try manager.openMaterial(context)
|
||||||
|
|
||||||
|
let checkpoint = ReadingCheckpoint(
|
||||||
|
schemaVersion: 2,
|
||||||
|
materialId: "restore-match",
|
||||||
|
representationFingerprint: "restore-match:block",
|
||||||
|
rendererKind: .block,
|
||||||
|
capturedAtMs: 1,
|
||||||
|
deviceId: "device-1",
|
||||||
|
deviceSequence: 1,
|
||||||
|
position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(manager.isCompatibleRestoreCheckpoint(checkpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_isCompatibleRestoreCheckpoint_rejects_mismatched_fingerprint() throws {
|
||||||
|
manager.adapter = spyAdapter
|
||||||
|
let context = ReadingMaterialContext(
|
||||||
|
readingTargetType: .knowledgeSource,
|
||||||
|
materialId: "restore-mismatch",
|
||||||
|
materialType: .markdown,
|
||||||
|
filePath: "",
|
||||||
|
fileName: "doc.md",
|
||||||
|
fileType: "markdown"
|
||||||
|
)
|
||||||
|
try manager.openMaterial(context)
|
||||||
|
|
||||||
|
let checkpoint = ReadingCheckpoint(
|
||||||
|
schemaVersion: 2,
|
||||||
|
materialId: "restore-mismatch",
|
||||||
|
representationFingerprint: "stale-fingerprint",
|
||||||
|
rendererKind: .block,
|
||||||
|
capturedAtMs: 1,
|
||||||
|
deviceId: "device-1",
|
||||||
|
deviceSequence: 1,
|
||||||
|
position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_isCompatibleRestoreCheckpoint_rejects_mismatched_renderer_kind() throws {
|
||||||
|
manager.adapter = spyAdapter
|
||||||
|
let context = ReadingMaterialContext(
|
||||||
|
readingTargetType: .knowledgeSource,
|
||||||
|
materialId: "restore-renderer",
|
||||||
|
materialType: .markdown,
|
||||||
|
filePath: "",
|
||||||
|
fileName: "doc.md",
|
||||||
|
fileType: "markdown"
|
||||||
|
)
|
||||||
|
try manager.openMaterial(context)
|
||||||
|
|
||||||
|
let checkpoint = ReadingCheckpoint(
|
||||||
|
schemaVersion: 2,
|
||||||
|
materialId: "restore-renderer",
|
||||||
|
representationFingerprint: "restore-renderer:block",
|
||||||
|
rendererKind: .pdf,
|
||||||
|
capturedAtMs: 1,
|
||||||
|
deviceId: "device-1",
|
||||||
|
deviceSequence: 1,
|
||||||
|
position: .pdf(pageNumber: 1, pageProgress: 0, overallProgress: 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,10 +11,11 @@ final class PositionAdapterTests: XCTestCase {
|
|||||||
blockId: "b1",
|
blockId: "b1",
|
||||||
scrollProgress: 0.5
|
scrollProgress: 0.5
|
||||||
)
|
)
|
||||||
if case .markdown(_, let progress) = pos! {
|
if case .block(_, let progress, let overallProgress) = pos! {
|
||||||
XCTAssertEqual(progress, 0.5)
|
XCTAssertEqual(progress, 0.5)
|
||||||
|
XCTAssertEqual(overallProgress, 0.5)
|
||||||
} else {
|
} else {
|
||||||
XCTFail("Expected markdown position")
|
XCTFail("Expected block position")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,10 +25,11 @@ final class PositionAdapterTests: XCTestCase {
|
|||||||
blockId: "b1",
|
blockId: "b1",
|
||||||
scrollProgress: -0.5
|
scrollProgress: -0.5
|
||||||
)
|
)
|
||||||
if case .markdown(_, let progress) = pos! {
|
if case .block(_, let progress, let overallProgress) = pos! {
|
||||||
XCTAssertEqual(progress, 0.0, "Negative progress should clamp to 0")
|
XCTAssertEqual(progress, 0.0, "Negative progress should clamp to 0")
|
||||||
|
XCTAssertEqual(overallProgress, 0.0)
|
||||||
} else {
|
} else {
|
||||||
XCTFail("Expected markdown position")
|
XCTFail("Expected block position")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,10 +39,11 @@ final class PositionAdapterTests: XCTestCase {
|
|||||||
blockId: "b1",
|
blockId: "b1",
|
||||||
scrollProgress: 1.5
|
scrollProgress: 1.5
|
||||||
)
|
)
|
||||||
if case .markdown(_, let progress) = pos! {
|
if case .block(_, let progress, let overallProgress) = pos! {
|
||||||
XCTAssertEqual(progress, 1.0, "Progress >1 should clamp to 1")
|
XCTAssertEqual(progress, 1.0, "Progress >1 should clamp to 1")
|
||||||
|
XCTAssertEqual(overallProgress, 1.0)
|
||||||
} else {
|
} else {
|
||||||
XCTFail("Expected markdown position")
|
XCTFail("Expected block position")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,10 +53,11 @@ final class PositionAdapterTests: XCTestCase {
|
|||||||
blockId: "b1",
|
blockId: "b1",
|
||||||
scrollProgress: Float.nan
|
scrollProgress: Float.nan
|
||||||
)
|
)
|
||||||
if case .markdown(_, let progress) = pos! {
|
if case .block(_, let progress, let overallProgress) = pos! {
|
||||||
XCTAssertEqual(progress, 0.0, "NaN progress should clamp to 0")
|
XCTAssertEqual(progress, 0.0, "NaN progress should clamp to 0")
|
||||||
|
XCTAssertEqual(overallProgress, 0.0)
|
||||||
} else {
|
} else {
|
||||||
XCTFail("Expected markdown position")
|
XCTFail("Expected block position")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,8 +82,8 @@ final class PositionAdapterTests: XCTestCase {
|
|||||||
func test_text_position() {
|
func test_text_position() {
|
||||||
let pos = ReadingPositionAdapter.buildPosition(
|
let pos = ReadingPositionAdapter.buildPosition(
|
||||||
materialType: .text,
|
materialType: .text,
|
||||||
lineNumber: 42,
|
scrollProgress: 0.7,
|
||||||
scrollProgress: 0.7
|
lineNumber: 42
|
||||||
)
|
)
|
||||||
if case .text(let line, let progress) = pos! {
|
if case .text(let line, let progress) = pos! {
|
||||||
XCTAssertEqual(line, 42)
|
XCTAssertEqual(line, 42)
|
||||||
@ -108,9 +112,9 @@ final class PositionAdapterTests: XCTestCase {
|
|||||||
func test_epub_position() {
|
func test_epub_position() {
|
||||||
let pos = ReadingPositionAdapter.buildPosition(
|
let pos = ReadingPositionAdapter.buildPosition(
|
||||||
materialType: .epub,
|
materialType: .epub,
|
||||||
chapterId: "ch3",
|
|
||||||
chapterProgress: 0.4,
|
chapterProgress: 0.4,
|
||||||
overallProgress: 0.2
|
overallProgress: 0.2,
|
||||||
|
chapterId: "ch3"
|
||||||
)
|
)
|
||||||
if case .epub(let ch, let chProg, let overall) = pos! {
|
if case .epub(let ch, let chProg, let overall) = pos! {
|
||||||
XCTAssertEqual(ch, "ch3")
|
XCTAssertEqual(ch, "ch3")
|
||||||
|
|||||||
@ -80,18 +80,18 @@ final class QuizQuestionTypeTests: XCTestCase {
|
|||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
func testQuizQuestionTypeProperty() {
|
func testQuizQuestionTypeProperty() {
|
||||||
let q = QuizQuestion(id: "1", quizId: nil, type: "choice", stem: nil, options: nil, answer: nil, explanation: nil, orderIndex: nil)
|
let q = QuizQuestion(id: "1", quizId: nil, stem: nil, questionText: nil, questionType: "choice", options: nil, answer: nil, correctAnswer: nil, explanation: nil, orderIndex: nil)
|
||||||
XCTAssertEqual(q.questionType, .choice)
|
XCTAssertEqual(q.questionTypeEnum, .choice)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testQuizQuestionUnknownTypeProperty() {
|
func testQuizQuestionUnknownTypeProperty() {
|
||||||
let q = QuizQuestion(id: "1", quizId: nil, type: "essay", stem: nil, options: nil, answer: nil, explanation: nil, orderIndex: nil)
|
let q = QuizQuestion(id: "1", quizId: nil, stem: nil, questionText: nil, questionType: "essay", options: nil, answer: nil, correctAnswer: nil, explanation: nil, orderIndex: nil)
|
||||||
XCTAssertNil(q.questionType)
|
XCTAssertNil(q.questionTypeEnum)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testQuizQuestionNilTypeProperty() {
|
func testQuizQuestionNilTypeProperty() {
|
||||||
let q = QuizQuestion(id: "1", quizId: nil, type: nil, stem: nil, options: nil, answer: nil, explanation: nil, orderIndex: nil)
|
let q = QuizQuestion(id: "1", quizId: nil, stem: nil, questionText: nil, questionType: nil, options: nil, answer: nil, correctAnswer: nil, explanation: nil, orderIndex: nil)
|
||||||
XCTAssertNil(q.questionType)
|
XCTAssertNil(q.questionTypeEnum)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
|
|||||||
@ -239,4 +239,51 @@ final class ReadingPositionAdapterTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTAssertNil(pos)
|
XCTAssertNil(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Restore fallback
|
||||||
|
|
||||||
|
func test_blockIdForRestore_prefers_existing_blockId() {
|
||||||
|
let blocks: [DocumentBlock] = [
|
||||||
|
.heading(id: "h1", level: 1, text: "Title"),
|
||||||
|
.paragraph(id: "p1", text: "Content"),
|
||||||
|
.paragraph(id: "p2", text: "Tail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let restored = ReadingPositionAdapter.blockIdForRestore(
|
||||||
|
from: .block(blockId: "p1", blockProgress: 0.25, overallProgress: 0.8),
|
||||||
|
in: blocks
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(restored, "p1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_blockIdForRestore_fallsBack_to_progress_when_blockId_missing() {
|
||||||
|
let blocks: [DocumentBlock] = [
|
||||||
|
.heading(id: "h1", level: 1, text: "Title"),
|
||||||
|
.paragraph(id: "p1", text: "Content"),
|
||||||
|
.paragraph(id: "p2", text: "Tail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let restored = ReadingPositionAdapter.blockIdForRestore(
|
||||||
|
from: .block(blockId: "missing", blockProgress: 0.1, overallProgress: 0.9),
|
||||||
|
in: blocks
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(restored, "p2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_blockIdForRestore_text_out_of_range_fallsBack_to_scroll_progress() {
|
||||||
|
let blocks: [DocumentBlock] = [
|
||||||
|
.heading(id: "h1", level: 1, text: "Title"),
|
||||||
|
.paragraph(id: "p1", text: "Content"),
|
||||||
|
.paragraph(id: "p2", text: "Tail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let restored = ReadingPositionAdapter.blockIdForRestore(
|
||||||
|
from: .text(lineNumber: 99, scrollProgress: 0.45),
|
||||||
|
in: blocks
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(restored, "p1")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,95 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import AIStudyApp
|
@testable import AIStudyApp
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class SpyReadingRuntimeAdapter: ReadingRuntimeAdapter {
|
||||||
|
var fingerprint = "fingerprint-1"
|
||||||
|
private var nextSequence: UInt64 = 1
|
||||||
|
|
||||||
|
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
|
||||||
|
"spy-session"
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseSession(_ sessionId: String) throws {}
|
||||||
|
func resumeSession(_ sessionId: String) throws {}
|
||||||
|
func closeSession(_ sessionId: String, timestampMs: Int64) throws {}
|
||||||
|
|
||||||
|
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialOpened)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushClosed(sessionId: String, materialId: String, delta: UInt32, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialClosed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushPositionChanged(sessionId: String, materialId: String, position: ReadingPosition, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, position: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, checkpoint: checkpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, position: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, checkpoint: checkpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
|
||||||
|
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .markedAsRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportEvents(limit: UInt32, timestampMs: Int64) -> [ReadingEventV2] { [] }
|
||||||
|
func ackEvents(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) }
|
||||||
|
func markFailed(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) }
|
||||||
|
func reloadStaleEvents() -> UInt32 { 0 }
|
||||||
|
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { 0 }
|
||||||
|
|
||||||
|
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
|
||||||
|
fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
|
||||||
|
"\(sourceFingerprint)|\(rendererKind)|\(parserVersion)|\(schemaVersion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeEvent(
|
||||||
|
sessionId: String,
|
||||||
|
materialId: String,
|
||||||
|
eventType: ReadingEventTypeV2,
|
||||||
|
position: ReadingPosition? = nil,
|
||||||
|
checkpoint: ReadingCheckpoint? = nil
|
||||||
|
) -> ReadingEventV2 {
|
||||||
|
defer { nextSequence += 1 }
|
||||||
|
return ReadingEventV2(
|
||||||
|
eventId: "evt-\(nextSequence)",
|
||||||
|
clientSessionId: sessionId,
|
||||||
|
materialId: materialId,
|
||||||
|
eventType: eventType,
|
||||||
|
position: position,
|
||||||
|
checkpoint: checkpoint,
|
||||||
|
activeSecondsDelta: 0,
|
||||||
|
timestampMs: 1_700_000_000_000,
|
||||||
|
sequence: nextSequence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SessionManagerTests: XCTestCase {
|
final class SessionManagerTests: XCTestCase {
|
||||||
|
|
||||||
var manager: ReadingRuntimeSessionManager!
|
var manager: ReadingRuntimeSessionManager!
|
||||||
var noopAdapter: NoopReadingRuntimeAdapter!
|
var noopAdapter: NoopReadingRuntimeAdapter!
|
||||||
|
var spyAdapter: SpyReadingRuntimeAdapter!
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
manager = ReadingRuntimeSessionManager.shared
|
manager = ReadingRuntimeSessionManager.shared
|
||||||
noopAdapter = NoopReadingRuntimeAdapter()
|
noopAdapter = NoopReadingRuntimeAdapter()
|
||||||
|
spyAdapter = SpyReadingRuntimeAdapter()
|
||||||
manager.adapter = noopAdapter
|
manager.adapter = noopAdapter
|
||||||
// Ensure clean state
|
// Ensure clean state
|
||||||
manager.closeMaterial()
|
manager.closeMaterial()
|
||||||
@ -122,4 +201,84 @@ final class SessionManagerTests: XCTestCase {
|
|||||||
// Also acceptable: state was reset
|
// Also acceptable: state was reset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Restore checkpoint compatibility
|
||||||
|
|
||||||
|
func test_isCompatibleRestoreCheckpoint_matches_active_representation() throws {
|
||||||
|
manager.adapter = spyAdapter
|
||||||
|
let context = ReadingMaterialContext(
|
||||||
|
readingTargetType: .knowledgeSource,
|
||||||
|
materialId: "restore-match",
|
||||||
|
materialType: .markdown,
|
||||||
|
filePath: "",
|
||||||
|
fileName: "doc.md",
|
||||||
|
fileType: "markdown"
|
||||||
|
)
|
||||||
|
try manager.openMaterial(context)
|
||||||
|
|
||||||
|
let checkpoint = ReadingCheckpoint(
|
||||||
|
schemaVersion: 2,
|
||||||
|
materialId: "restore-match",
|
||||||
|
representationFingerprint: "restore-match|block|block-parser-v1|2",
|
||||||
|
rendererKind: .block,
|
||||||
|
capturedAtMs: 1,
|
||||||
|
deviceId: "device-1",
|
||||||
|
deviceSequence: 1,
|
||||||
|
position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(manager.isCompatibleRestoreCheckpoint(checkpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_isCompatibleRestoreCheckpoint_rejects_mismatched_fingerprint() throws {
|
||||||
|
manager.adapter = spyAdapter
|
||||||
|
let context = ReadingMaterialContext(
|
||||||
|
readingTargetType: .knowledgeSource,
|
||||||
|
materialId: "restore-mismatch",
|
||||||
|
materialType: .markdown,
|
||||||
|
filePath: "",
|
||||||
|
fileName: "doc.md",
|
||||||
|
fileType: "markdown"
|
||||||
|
)
|
||||||
|
try manager.openMaterial(context)
|
||||||
|
|
||||||
|
let checkpoint = ReadingCheckpoint(
|
||||||
|
schemaVersion: 2,
|
||||||
|
materialId: "restore-mismatch",
|
||||||
|
representationFingerprint: "stale-fingerprint",
|
||||||
|
rendererKind: .block,
|
||||||
|
capturedAtMs: 1,
|
||||||
|
deviceId: "device-1",
|
||||||
|
deviceSequence: 1,
|
||||||
|
position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_isCompatibleRestoreCheckpoint_rejects_mismatched_renderer_kind() throws {
|
||||||
|
manager.adapter = spyAdapter
|
||||||
|
let context = ReadingMaterialContext(
|
||||||
|
readingTargetType: .knowledgeSource,
|
||||||
|
materialId: "restore-renderer",
|
||||||
|
materialType: .markdown,
|
||||||
|
filePath: "",
|
||||||
|
fileName: "doc.md",
|
||||||
|
fileType: "markdown"
|
||||||
|
)
|
||||||
|
try manager.openMaterial(context)
|
||||||
|
|
||||||
|
let checkpoint = ReadingCheckpoint(
|
||||||
|
schemaVersion: 2,
|
||||||
|
materialId: "restore-renderer",
|
||||||
|
representationFingerprint: "restore-renderer|block|block-parser-v1|2",
|
||||||
|
rendererKind: .pdf,
|
||||||
|
capturedAtMs: 1,
|
||||||
|
deviceId: "device-1",
|
||||||
|
deviceSequence: 1,
|
||||||
|
position: .pdf(pageNumber: 1, pageProgress: 0, overallProgress: 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,14 +24,23 @@ final class V2AdapterSmokeTests: XCTestCase {
|
|||||||
|
|
||||||
func test_noopAdapter_startSession_returnsSessionId() {
|
func test_noopAdapter_startSession_returnsSessionId() {
|
||||||
let adapter = NoopReadingRuntimeAdapter()
|
let adapter = NoopReadingRuntimeAdapter()
|
||||||
let ref = ReadingMaterialRef(materialId: "test-material")
|
let ref = ReadingMaterialRefV2(
|
||||||
|
materialId: "test-material",
|
||||||
|
filePath: "/tmp/test.md",
|
||||||
|
fileName: "test.md",
|
||||||
|
fileType: "markdown",
|
||||||
|
mimeType: nil,
|
||||||
|
fileSize: nil,
|
||||||
|
contentHash: nil,
|
||||||
|
createdAtMs: nil
|
||||||
|
)
|
||||||
let sid = try! adapter.startSession(material: ref, timestampMs: 1_700_000_000_000)
|
let sid = try! adapter.startSession(material: ref, timestampMs: 1_700_000_000_000)
|
||||||
XCTAssertTrue(sid.hasPrefix("noop-"))
|
XCTAssertTrue(sid.hasPrefix("noop-"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_noopAdapter_closeSession_doesNotThrow() {
|
func test_noopAdapter_closeSession_doesNotThrow() {
|
||||||
let adapter = NoopReadingRuntimeAdapter()
|
let adapter = NoopReadingRuntimeAdapter()
|
||||||
XCTAssertNoThrow(try adapter.closeSession("noop-session"))
|
XCTAssertNoThrow(try adapter.closeSession("noop-session", timestampMs: 1_700_000_000_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Buffer (Noop)
|
// MARK: - Buffer (Noop)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user