diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..67641aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.release +artifacts +docs/* +!docs/privacy-policy.html +!docs/support.html +Omo +plans +**/node_modules +**/.DS_Store diff --git a/.gitignore b/.gitignore index 8845ee2..8c24924 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ __pycache__/ *.pyc .superpowers/ .codex-screenshots/ +.artifacts/ .asc/ ios/ .DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..586e8be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --omit=dev + +COPY backend/ ./ +COPY docs/privacy-policy.html docs/support.html ./docs/ + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV OMO_PUBLIC_PAGES_DIR=/app/docs + +USER node + +CMD ["npm", "start"] diff --git a/Omo/Omo.xcodeproj/project.pbxproj b/Omo/Omo.xcodeproj/project.pbxproj index db20a79..21d4467 100644 --- a/Omo/Omo.xcodeproj/project.pbxproj +++ b/Omo/Omo.xcodeproj/project.pbxproj @@ -8,6 +8,11 @@ /* Begin PBXBuildFile section */ FA70C00E2FC000000000000E /* APIClientDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA70C00F2FC000000000000F /* APIClientDecodingTests.swift */; }; + FA70C0102FC0000000000010 /* RecallInteractionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA70C0112FC0000000000011 /* RecallInteractionStateTests.swift */; }; + FA70C0122FC0000000000012 /* KnowledgeLibrarySearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA70C0132FC0000000000013 /* KnowledgeLibrarySearchTests.swift */; }; + FA70C0142FC0000000000014 /* KnowledgeLibraryPaginationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA70C0152FC0000000000015 /* KnowledgeLibraryPaginationTests.swift */; }; + FA70C0162FC0000000000016 /* ScreenshotJobPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA70C0172FC0000000000017 /* ScreenshotJobPersistenceTests.swift */; }; + FA80C00C2FC000000000000C /* OmoCoreInteractionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA80C00D2FC000000000000D /* OmoCoreInteractionUITests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -18,17 +23,43 @@ remoteGlobalIDString = FA48BAF12FB8628200A66913; remoteInfo = Omo; }; + FA80C0042FC0000000000004 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FA48BAEA2FB8628200A66913 /* Project object */; + proxyType = 1; + remoteGlobalIDString = FA48BAF12FB8628200A66913; + remoteInfo = Omo; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ FA48BAF22FB8628200A66913 /* Omo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Omo.app; sourceTree = BUILT_PRODUCTS_DIR; }; FA70C0012FC0000000000001 /* OmoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OmoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FA70C00F2FC000000000000F /* APIClientDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientDecodingTests.swift; sourceTree = ""; }; + FA70C0112FC0000000000011 /* RecallInteractionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecallInteractionStateTests.swift; sourceTree = ""; }; + FA70C0132FC0000000000013 /* KnowledgeLibrarySearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnowledgeLibrarySearchTests.swift; sourceTree = ""; }; + FA70C0152FC0000000000015 /* KnowledgeLibraryPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnowledgeLibraryPaginationTests.swift; sourceTree = ""; }; + FA70C0172FC0000000000017 /* ScreenshotJobPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotJobPersistenceTests.swift; sourceTree = ""; }; + FA80C0012FC0000000000001 /* OmoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OmoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FA80C00D2FC000000000000D /* OmoCoreInteractionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmoCoreInteractionUITests.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + FA70C0202FC0000000000020 /* Exceptions for "Omo" folder in "Omo" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = FA48BAF12FB8628200A66913 /* Omo */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ FA48BAF42FB8628200A66913 /* Omo */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + FA70C0202FC0000000000020 /* Exceptions for "Omo" folder in "Omo" target */, + ); path = Omo; sourceTree = ""; }; @@ -49,6 +80,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FA80C0032FC0000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -57,6 +95,7 @@ children = ( FA48BAF42FB8628200A66913 /* Omo */, FA70C0022FC0000000000002 /* OmoTests */, + FA80C0022FC0000000000002 /* OmoUITests */, FA48BAF32FB8628200A66913 /* Products */, ); sourceTree = ""; @@ -66,6 +105,7 @@ children = ( FA48BAF22FB8628200A66913 /* Omo.app */, FA70C0012FC0000000000001 /* OmoTests.xctest */, + FA80C0012FC0000000000001 /* OmoUITests.xctest */, ); name = Products; sourceTree = ""; @@ -74,10 +114,22 @@ isa = PBXGroup; children = ( FA70C00F2FC000000000000F /* APIClientDecodingTests.swift */, + FA70C0112FC0000000000011 /* RecallInteractionStateTests.swift */, + FA70C0132FC0000000000013 /* KnowledgeLibrarySearchTests.swift */, + FA70C0152FC0000000000015 /* KnowledgeLibraryPaginationTests.swift */, + FA70C0172FC0000000000017 /* ScreenshotJobPersistenceTests.swift */, ); path = OmoTests; sourceTree = ""; }; + FA80C0022FC0000000000002 /* OmoUITests */ = { + isa = PBXGroup; + children = ( + FA80C00D2FC000000000000D /* OmoCoreInteractionUITests.swift */, + ); + path = OmoUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -123,6 +175,26 @@ productReference = FA70C0012FC0000000000001 /* OmoTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + FA80C0052FC0000000000005 /* OmoUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FA80C00B2FC000000000000B /* Build configuration list for PBXNativeTarget "OmoUITests" */; + buildPhases = ( + FA80C0062FC0000000000006 /* Sources */, + FA80C0032FC0000000000003 /* Frameworks */, + FA80C0072FC0000000000007 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + FA80C0082FC0000000000008 /* PBXTargetDependency */, + ); + name = OmoUITests; + packageProductDependencies = ( + ); + productName = OmoUITests; + productReference = FA80C0012FC0000000000001 /* OmoUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -140,6 +212,10 @@ CreatedOnToolsVersion = 17.0; TestTargetID = FA48BAF12FB8628200A66913; }; + FA80C0052FC0000000000005 = { + CreatedOnToolsVersion = 17.0; + TestTargetID = FA48BAF12FB8628200A66913; + }; }; }; buildConfigurationList = FA48BAED2FB8628200A66913 /* Build configuration list for PBXProject "Omo" */; @@ -161,6 +237,7 @@ targets = ( FA48BAF12FB8628200A66913 /* Omo */, FA70C0052FC0000000000005 /* OmoTests */, + FA80C0052FC0000000000005 /* OmoUITests */, ); }; /* End PBXProject section */ @@ -180,6 +257,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FA80C0072FC0000000000007 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -195,6 +279,18 @@ buildActionMask = 2147483647; files = ( FA70C00E2FC000000000000E /* APIClientDecodingTests.swift in Sources */, + FA70C0102FC0000000000010 /* RecallInteractionStateTests.swift in Sources */, + FA70C0122FC0000000000012 /* KnowledgeLibrarySearchTests.swift in Sources */, + FA70C0142FC0000000000014 /* KnowledgeLibraryPaginationTests.swift in Sources */, + FA70C0162FC0000000000016 /* ScreenshotJobPersistenceTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FA80C0062FC0000000000006 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FA80C00C2FC000000000000C /* OmoCoreInteractionUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -206,6 +302,11 @@ target = FA48BAF12FB8628200A66913 /* Omo */; targetProxy = FA70C0042FC0000000000004 /* PBXContainerItemProxy */; }; + FA80C0082FC0000000000008 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FA48BAF12FB8628200A66913 /* Omo */; + targetProxy = FA80C0042FC0000000000004 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -262,7 +363,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -320,7 +421,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -331,108 +432,152 @@ name = Release; }; FA48BAFE2FB8628400A66913 /* Debug */ = { + buildSettings = { + "ASSETCATALOG_COMPILER_APPICON_NAME" = AppIcon; + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME" = AccentColor; + "CODE_SIGN_STYLE" = Automatic; + "CURRENT_PROJECT_VERSION" = 3; + "DEVELOPMENT_TEAM" = 44589Y6FA6; + "ENABLE_PREVIEWS" = YES; + "GENERATE_INFOPLIST_FILE" = YES; + "INFOPLIST_FILE" = "Omo/Info.plist"; + "INFOPLIST_KEY_CFBundleDisplayName" = Omo; + "INFOPLIST_KEY_NSMicrophoneUsageDescription" = "\U7528\U8bed\U97f3\U63cf\U8ff0\U4f60\U60f3\U627e\U7684\U77e5\U8bc6\U3002"; + "INFOPLIST_KEY_NSSpeechRecognitionUsageDescription" = "\U628a\U4f60\U7684\U8bed\U97f3\U8f6c\U6210\U77e5\U8bc6\U5e93\U641c\U7d22\U6587\U5b57\U3002"; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation" = YES; + "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone" = UIInterfaceOrientationPortrait; + "LD_RUNPATH_SEARCH_PATHS" = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "MARKETING_VERSION" = "1.0"; + "OMO_API_BASE_URL" = "http://127.0.0.1:5174"; + "PRODUCT_BUNDLE_IDENTIFIER" = "com.maxhan.omo"; + "PRODUCT_NAME" = Omo; + "SWIFT_EMIT_LOC_STRINGS" = YES; + "SWIFT_VERSION" = "5.0"; + "TARGETED_DEVICE_FAMILY" = 1; + }; + isa = XCBuildConfiguration; + name = Debug; +}; + FA48BAFF2FB8628400A66913 /* Release */ = { + buildSettings = { + "ASSETCATALOG_COMPILER_APPICON_NAME" = AppIcon; + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME" = AccentColor; + "CODE_SIGN_STYLE" = Automatic; + "CURRENT_PROJECT_VERSION" = 3; + "DEVELOPMENT_TEAM" = 44589Y6FA6; + "ENABLE_PREVIEWS" = YES; + "GENERATE_INFOPLIST_FILE" = YES; + "INFOPLIST_FILE" = "Omo/Info.plist"; + "INFOPLIST_KEY_CFBundleDisplayName" = Omo; + "INFOPLIST_KEY_NSMicrophoneUsageDescription" = "\U7528\U8bed\U97f3\U63cf\U8ff0\U4f60\U60f3\U627e\U7684\U77e5\U8bc6\U3002"; + "INFOPLIST_KEY_NSSpeechRecognitionUsageDescription" = "\U628a\U4f60\U7684\U8bed\U97f3\U8f6c\U6210\U77e5\U8bc6\U5e93\U641c\U7d22\U6587\U5b57\U3002"; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation" = YES; + "INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone" = UIInterfaceOrientationPortrait; + "LD_RUNPATH_SEARCH_PATHS" = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "MARKETING_VERSION" = "1.0"; + "OMO_API_BASE_URL" = "https://omo-api-staging-staging.up.railway.app"; + "PRODUCT_BUNDLE_IDENTIFIER" = "com.maxhan.omo"; + "PRODUCT_NAME" = Omo; + "SWIFT_EMIT_LOC_STRINGS" = YES; + "SWIFT_VERSION" = "5.0"; + "TARGETED_DEVICE_FAMILY" = 1; + }; + isa = XCBuildConfiguration; + name = Release; +}; + FA70C0092FC0000000000009 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 44589Y6FA6; - ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Omo; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", + "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.maxhan.shibei; - PRODUCT_NAME = Omo; - SWIFT_EMIT_LOC_STRINGS = YES; + OTHER_CODE_SIGN_FLAGS = "--deep"; + PRODUCT_BUNDLE_IDENTIFIER = com.maxhan.omo.Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Omo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Omo"; }; name = Debug; }; - FA48BAFF2FB8628400A66913 /* Release */ = { + FA70C00A2FC000000000000A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 44589Y6FA6; - ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Omo; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", + "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.maxhan.shibei; - PRODUCT_NAME = Omo; - SWIFT_EMIT_LOC_STRINGS = YES; + OTHER_CODE_SIGN_FLAGS = "--deep"; + PRODUCT_BUNDLE_IDENTIFIER = com.maxhan.omo.Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Omo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Omo"; }; name = Release; }; - FA70C0092FC0000000000009 /* Debug */ = { + FA80C0092FC0000000000009 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 44589Y6FA6; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; - OTHER_CODE_SIGN_FLAGS = "--deep"; - PRODUCT_BUNDLE_IDENTIFIER = com.maxhan.omo.Tests; + PRODUCT_BUNDLE_IDENTIFIER = com.maxhan.omo.UITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Omo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Omo"; + TEST_TARGET_NAME = Omo; }; name = Debug; }; - FA70C00A2FC000000000000A /* Release */ = { + FA80C00A2FC000000000000A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 44589Y6FA6; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 1.0; - OTHER_CODE_SIGN_FLAGS = "--deep"; - PRODUCT_BUNDLE_IDENTIFIER = com.maxhan.omo.Tests; + PRODUCT_BUNDLE_IDENTIFIER = com.maxhan.omo.UITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Omo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Omo"; + TEST_TARGET_NAME = Omo; }; name = Release; }; @@ -466,6 +611,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FA80C00B2FC000000000000B /* Build configuration list for PBXNativeTarget "OmoUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FA80C0092FC0000000000009 /* Debug */, + FA80C00A2FC000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = FA48BAEA2FB8628200A66913 /* Project object */; diff --git a/Omo/Omo.xcodeproj/xcshareddata/xcschemes/Omo.xcscheme b/Omo/Omo.xcodeproj/xcshareddata/xcschemes/Omo.xcscheme index 784ab1c..4ea22dd 100644 --- a/Omo/Omo.xcodeproj/xcshareddata/xcschemes/Omo.xcscheme +++ b/Omo/Omo.xcodeproj/xcshareddata/xcschemes/Omo.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:Omo.xcodeproj"> + + + + + + + + Bool { + !hasConsent + } +} diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchArrow.imageset/Contents.json b/Omo/Omo/Assets.xcassets/FirstLaunchArrow.imageset/Contents.json new file mode 100644 index 0000000..58b85ae --- /dev/null +++ b/Omo/Omo/Assets.xcassets/FirstLaunchArrow.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "first-launch-arrow.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchArrow.imageset/first-launch-arrow.svg b/Omo/Omo/Assets.xcassets/FirstLaunchArrow.imageset/first-launch-arrow.svg new file mode 100644 index 0000000..d4d3fd9 --- /dev/null +++ b/Omo/Omo/Assets.xcassets/FirstLaunchArrow.imageset/first-launch-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchFolder.imageset/Contents.json b/Omo/Omo/Assets.xcassets/FirstLaunchFolder.imageset/Contents.json new file mode 100644 index 0000000..3d3a032 --- /dev/null +++ b/Omo/Omo/Assets.xcassets/FirstLaunchFolder.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "first-launch-folder.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchFolder.imageset/first-launch-folder.png b/Omo/Omo/Assets.xcassets/FirstLaunchFolder.imageset/first-launch-folder.png new file mode 100644 index 0000000..f0166e6 Binary files /dev/null and b/Omo/Omo/Assets.xcassets/FirstLaunchFolder.imageset/first-launch-folder.png differ diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchMenu.imageset/Contents.json b/Omo/Omo/Assets.xcassets/FirstLaunchMenu.imageset/Contents.json new file mode 100644 index 0000000..eb690fc --- /dev/null +++ b/Omo/Omo/Assets.xcassets/FirstLaunchMenu.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "first-launch-menu.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchMenu.imageset/first-launch-menu.svg b/Omo/Omo/Assets.xcassets/FirstLaunchMenu.imageset/first-launch-menu.svg new file mode 100644 index 0000000..33275b5 --- /dev/null +++ b/Omo/Omo/Assets.xcassets/FirstLaunchMenu.imageset/first-launch-menu.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchPanel.imageset/Contents.json b/Omo/Omo/Assets.xcassets/FirstLaunchPanel.imageset/Contents.json new file mode 100644 index 0000000..af135c4 --- /dev/null +++ b/Omo/Omo/Assets.xcassets/FirstLaunchPanel.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "first-launch-panel.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchPanel.imageset/first-launch-panel.png b/Omo/Omo/Assets.xcassets/FirstLaunchPanel.imageset/first-launch-panel.png new file mode 100644 index 0000000..1cd2b55 Binary files /dev/null and b/Omo/Omo/Assets.xcassets/FirstLaunchPanel.imageset/first-launch-panel.png differ diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchUpload.imageset/Contents.json b/Omo/Omo/Assets.xcassets/FirstLaunchUpload.imageset/Contents.json new file mode 100644 index 0000000..164d83d --- /dev/null +++ b/Omo/Omo/Assets.xcassets/FirstLaunchUpload.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "first-launch-upload.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Omo/Omo/Assets.xcassets/FirstLaunchUpload.imageset/first-launch-upload.svg b/Omo/Omo/Assets.xcassets/FirstLaunchUpload.imageset/first-launch-upload.svg new file mode 100644 index 0000000..4746967 --- /dev/null +++ b/Omo/Omo/Assets.xcassets/FirstLaunchUpload.imageset/first-launch-upload.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Omo/Omo/Assets.xcassets/KnowledgeLibraryBack.imageset/Contents.json b/Omo/Omo/Assets.xcassets/KnowledgeLibraryBack.imageset/Contents.json new file mode 100644 index 0000000..b62f2a5 --- /dev/null +++ b/Omo/Omo/Assets.xcassets/KnowledgeLibraryBack.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "knowledge-library-back.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Omo/Omo/Assets.xcassets/KnowledgeLibraryBack.imageset/knowledge-library-back.svg b/Omo/Omo/Assets.xcassets/KnowledgeLibraryBack.imageset/knowledge-library-back.svg new file mode 100644 index 0000000..33dea32 --- /dev/null +++ b/Omo/Omo/Assets.xcassets/KnowledgeLibraryBack.imageset/knowledge-library-back.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Omo/Omo/Assets.xcassets/KnowledgeLibraryMicrophone.imageset/Contents.json b/Omo/Omo/Assets.xcassets/KnowledgeLibraryMicrophone.imageset/Contents.json new file mode 100644 index 0000000..8c98fbc --- /dev/null +++ b/Omo/Omo/Assets.xcassets/KnowledgeLibraryMicrophone.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "knowledge-library-microphone.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Omo/Omo/Assets.xcassets/KnowledgeLibraryMicrophone.imageset/knowledge-library-microphone.svg b/Omo/Omo/Assets.xcassets/KnowledgeLibraryMicrophone.imageset/knowledge-library-microphone.svg new file mode 100644 index 0000000..5b69b66 --- /dev/null +++ b/Omo/Omo/Assets.xcassets/KnowledgeLibraryMicrophone.imageset/knowledge-library-microphone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Omo/Omo/ContentView.swift b/Omo/Omo/ContentView.swift index 645d953..651236a 100644 --- a/Omo/Omo/ContentView.swift +++ b/Omo/Omo/ContentView.swift @@ -19,6 +19,7 @@ struct ContentView: View { @EnvironmentObject private var store: OmoStore @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var showsAdd = false + @State private var showsSettings = false @State private var showsLaunch = !ProcessInfo.processInfo.arguments.contains("-OmoSkipLaunch") var body: some View { @@ -28,23 +29,28 @@ struct ContentView: View { .id(store.selectedTab) .transition(.opacity.combined(with: .scale(scale: 0.985))) } - .safeAreaInset(edge: .bottom) { - OmoTabBar(selection: $store.selectedTab) - .padding(.horizontal, 20) - .padding(.bottom, 8) - } .task { - await store.load() - #if DEBUG + #if DEBUG || OMO_TESTING let arguments = ProcessInfo.processInfo.arguments + if !arguments.contains("-OmoUseFixtures") { await store.load() } if arguments.contains("-OmoOpenLibrary") { store.selectedTab = .library } - if arguments.contains("-OmoAutoRecall") { store.draw() } + store.applyKnowledgeLibraryDebugArguments(arguments) + store.applyScreenshotJobDebugArguments(arguments) + if let index = arguments.firstIndex(of: "-OmoNotificationCardID"), + arguments.indices.contains(index + 1) { + store.handleRecallNotification(cardID: arguments[index + 1]) + } + #else + await store.load() #endif } .sheet(isPresented: $showsAdd) { AddScreenshotView() .environmentObject(store) } + .sheet(isPresented: $showsSettings) { + SettingsView() + } .onChange(of: showsAdd) { _, isPresented in guard !isPresented, let card = store.pendingCard else { return } store.pendingCard = nil @@ -54,11 +60,10 @@ struct ContentView: View { } } .fullScreenCover(item: $store.presentedCard) { card in - RecallView(card: card) - .environmentObject(store) + LibraryCardDetailView(card: card) } .overlay(alignment: .top) { - if !store.message.isEmpty { + if !store.message.isEmpty, store.selectedTab != .today { Text(store.message) .font(.footnote.weight(.semibold)) .foregroundStyle(OmoTheme.ink) @@ -88,176 +93,108 @@ struct ContentView: View { private var currentPage: some View { switch store.selectedTab { case .today: - TodayView(onAdd: { showsAdd = true }) + RecallHomeView( + onOpenLibrary: { store.selectedTab = .library }, + onOpenProfile: { store.selectedTab = .profile }, + onOpenSettings: { showsSettings = true } + ) case .library: - LibraryView(onAdd: { showsAdd = true }) + KnowledgeLibraryView( + cards: store.cards, + screenshotJobs: store.screenshotJobs, + onBack: { store.selectedTab = .today }, + onAdd: { showsAdd = true }, + onRetryJob: { job in + Task { await store.retryScreenshotJob(job) } + }, + onOpenCard: { store.presentedCard = $0 } + ) case .profile: - ProfileView() + ProfileView(onBack: { store.selectedTab = .today }) } } } -private struct TodayView: View { - @EnvironmentObject private var store: OmoStore - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var breathing = false - let onAdd: () -> Void - - var body: some View { - VStack(spacing: 0) { - HStack { - Text("今日") - .font(.system(size: 26, weight: .bold)) - Spacer() - Button(action: onAdd) { - Image(systemName: "plus") - .font(.system(size: 18, weight: .semibold)) - .frame(width: 44, height: 44) - .background(OmoTheme.surface, in: Circle()) - } - .buttonStyle(SpringPressStyle()) - .accessibilityLabel("添加截图") - } - .foregroundStyle(OmoTheme.ink) - .padding(.horizontal, OmoTheme.pageInset) - .padding(.top, 12) - - Spacer(minLength: 20) - if store.isLoading { - ProgressView("正在寻找记忆") - .tint(OmoTheme.primary) - } else if store.dueCards.isEmpty { - emptyState - } else { - drawState - } - - Spacer() - } - } - - private var emptyState: some View { - VStack(spacing: 20) { - Image("OmoPoseStretch") - .resizable() - .scaledToFit() - .frame(width: 210, height: 210) - .offset(y: breathing ? -7 : 4) - .rotationEffect(.degrees(breathing ? 1.5 : -1.5)) - Text(store.cards.isEmpty ? "还没有可以唤醒的记忆" : "今天的记忆都收好了") - .font(.title3.bold()) - .foregroundStyle(OmoTheme.ink) - Text(store.cards.isEmpty ? "先添加一张社媒截图" : "它们会在合适的时间回来") - .foregroundStyle(OmoTheme.muted) - if store.cards.isEmpty { - PrimaryButton(title: "添加内容", action: onAdd) - } - } - .padding(.horizontal, OmoTheme.pageInset) - .onAppear(perform: startBreathing) - } - - private var drawState: some View { - VStack(spacing: 18) { - Text("今天,唤醒一点记忆") - .font(.title2.bold()) - .foregroundStyle(OmoTheme.ink) - Text("从自己的过去抽一张") - .foregroundStyle(OmoTheme.muted) - - BreathingCardDeck(active: breathing) - - PrimaryButton(title: "召回一张", systemImage: "sparkles", action: store.draw) - Text("还有 \(store.dueCards.count) 张在等你,随时可以停下") - .font(.caption) - .foregroundStyle(OmoTheme.muted) - } - .padding(.horizontal, OmoTheme.pageInset) - .onAppear(perform: startBreathing) - } - - private func startBreathing() { - guard !reduceMotion, !breathing else { return } - withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) { - breathing = true - } - } -} - -private struct LibraryView: View { - @EnvironmentObject private var store: OmoStore - let onAdd: () -> Void +private struct LibraryCardDetailView: View { + let card: MemoryCard + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { - Group { - if store.cards.isEmpty { - ContentUnavailableView( - "知识库还是空的", - systemImage: "rectangle.stack.badge.plus", - description: Text("添加截图后,记忆卡会出现在这里。") - ) - } else { - List { - ForEach(store.cards) { card in - Button { - store.presentedCard = card - } label: { - MemoryCardRow(card: card) - } - .buttonStyle(.plain) - .listRowBackground(OmoTheme.surface) - .swipeActions { - Button("删除", role: .destructive) { - Task { await store.delete(card) } - } - } + ScrollView { + VStack(alignment: .leading, spacing: 18) { + HStack { + RarityBadge(value: card.rarity) + Spacer() + Text("掌握 · \(card.masteryTitle)") + .font(.caption.weight(.semibold)) + .foregroundStyle(OmoTheme.primary) + } + Text(card.coreKnowledge) + .font(.title3.bold()) + .foregroundStyle(OmoTheme.ink) + Text(card.explanation) + .foregroundStyle(OmoTheme.muted) + Divider() + Text(card.sourceTitle) + .font(.subheadline.weight(.semibold)) + if card.sourceIsVerified, + let value = card.sourceUrl, + let url = URL(string: value) { + Link(destination: url) { + Label("查看原文", systemImage: "arrow.up.right.square") + .frame(minHeight: 44) } } - .scrollContentBackground(.hidden) } + .padding(OmoTheme.pageInset) } .background(OmoTheme.background) - .navigationTitle("知识库") + .navigationTitle("完整知识") + .navigationBarTitleDisplayMode(.inline) .toolbar { - Button(action: onAdd) { Image(systemName: "plus") } - .accessibilityLabel("添加截图") + Button("完成") { dismiss() } } } } } -private struct MemoryCardRow: View { - let card: MemoryCard +private struct SettingsView: View { + @Environment(\.dismiss) private var dismiss + @AppStorage(AIProcessingConsent.defaultsKey) private var allowsAIProcessing = false var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - RarityBadge(value: card.rarity) - Text("掌握 · \(card.masteryTitle)") - .font(.caption) - .foregroundStyle(OmoTheme.primary) - Spacer() - Text(card.nextReviewText) - .font(.caption) - .foregroundStyle(OmoTheme.muted) + NavigationStack { + List { + Section("复习") { + Label("默认每轮最多 10 张", systemImage: "rectangle.stack") + Label("刮开 80% 后进行自评", systemImage: "hand.draw") + } + Section { + if allowsAIProcessing { + Button("撤回 AI 处理许可") { + allowsAIProcessing = false + } + } else { + Text("下次上传截图时会询问 AI 处理许可") + .foregroundStyle(.secondary) + } + NavigationLink("隐私说明") { + OmoPrivacyView() + } + Link("联系支持", destination: URL(string: "mailto:mingyuhan0814@gmail.com")!) + } header: { + Text("隐私") + } footer: { + Text("撤回后,现有记忆卡不受影响;下次上传截图时会重新询问。") + } } - Text(card.coreKnowledge) - .font(.body.weight(.semibold)) - .foregroundStyle(OmoTheme.ink) - .multilineTextAlignment(.leading) - Text(card.sourceTitle) - .font(.caption) - .foregroundStyle(OmoTheme.muted) - .lineLimit(1) - if card.sourceIsVerified { - Label("TickHub 已核验", systemImage: "checkmark.seal.fill") - .font(.caption2.weight(.semibold)) - .foregroundStyle(OmoTheme.primary) + .navigationTitle("Settings") + .toolbar { + Button("完成") { dismiss() } } } - .padding(.vertical, 8) } } @@ -265,15 +202,18 @@ private struct AddScreenshotView: View { @EnvironmentObject private var store: OmoStore @Environment(\.dismiss) private var dismiss @State private var selection: PhotosPickerItem? + @State private var showsAIConsent = false @State private var pulse = false + @StateObject private var uploadCoordinator = ScreenshotUploadCoordinator() + @AppStorage(AIProcessingConsent.defaultsKey) private var allowsAIProcessing = false var body: some View { - let isCreating = store.isCreating + let isSubmitting = uploadCoordinator.isSubmitting NavigationStack { VStack(spacing: 24) { Spacer() ZStack { - if isCreating { + if isSubmitting { OmoOrbit().scaleEffect(0.7) OmoAtlasPlayer( asset: "OmoMotionRunAtlas", @@ -297,17 +237,17 @@ private struct AddScreenshotView: View { .multilineTextAlignment(.center) PhotosPicker(selection: $selection, matching: .images) { - Label(isCreating ? "正在生成" : "选择截图", systemImage: "photo") + Label(isSubmitting ? "正在接收截图" : "选择截图", systemImage: "photo") .frame(maxWidth: .infinity) .frame(height: 54) .foregroundStyle(.white) .background(OmoTheme.primary, in: RoundedRectangle(cornerRadius: 16)) } - .disabled(isCreating) + .disabled(isSubmitting) .buttonStyle(SpringPressStyle()) - if isCreating { - ProgressView("正在识别标题并通过 TickHub 核对来源") + if isSubmitting { + ProgressView("正在安全保存任务") .tint(OmoTheme.primary) } Spacer() @@ -326,468 +266,74 @@ private struct AddScreenshotView: View { store.message = "无法读取这张图片。" return } - if await store.createCard(from: data) { dismiss() } - selection = nil - } - } - .onAppear { - withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { pulse = true } - } - } - } -} - -private struct RecallView: View { - enum Phase { case summoning, recall, revealed, saving, complete } - - @EnvironmentObject private var store: OmoStore - @Environment(\.dismiss) private var dismiss - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var card: MemoryCard - @State private var phase: Phase = .summoning - @State private var summonStage: MemorySummonStage = .running - @State private var scratchProgress: CGFloat = 0 - @State private var contentEntered = false - @State private var selectedAssessment: MemoryAssessment? - @State private var reactionTrigger = 0 - @State private var error = "" - - init(card: MemoryCard) { - _card = State(initialValue: card) - } - - var body: some View { - ZStack { - background - .ignoresSafeArea() - .animation(.easeInOut(duration: 0.42), value: phase) - switch phase { - case .summoning: - summonView.transition(.opacity) - case .recall, .revealed: - recallView.transition(.opacity.combined(with: .scale(scale: 0.98))) - case .saving: - savingView.transition(.opacity.combined(with: .scale(scale: 0.94))) - case .complete: - completeView.transition(.opacity.combined(with: .scale(scale: 0.88))) - } - } - .task { await playSummon() } - } - - private var background: Color { - switch phase { - case .summoning, .saving: OmoTheme.mist - case .recall: OmoTheme.recall - case .revealed: OmoTheme.surface - case .complete: OmoTheme.success - } - } - - private var summonView: some View { - VStack(spacing: 18) { - VStack(spacing: 6) { - Text("正在从你的过去召回") - .font(.system(.title2, design: .rounded, weight: .bold)) - Text(summonCaption) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(OmoTheme.muted) - .contentTransition(.numericText()) - } - - ZStack { - Image("RecallFolder") - .resizable() - .scaledToFit() - .frame(width: 128, height: 128) - .offset(x: -126, y: 118) - .rotationEffect(.degrees(summonStage == .rummaging ? -5 : 0)) - .scaleEffect(summonStage == .rummaging ? 1.08 : 1) - - if summonStage == .landing || summonStage == .revealed { - OmoOrbit() - .transition(.scale.combined(with: .opacity)) - ForEach(0..<2, id: \.self) { index in - RoundedRectangle(cornerRadius: 26) - .fill(OmoTheme.surface.opacity(index == 0 ? 0.55 : 0.75)) - .frame(width: 238, height: 318) - .rotationEffect(.degrees(index == 0 ? -7 : 6)) - .offset(x: index == 0 ? -17 : 16, y: 13) - } - - VStack(spacing: 18) { - RarityBadge(value: card.rarity) - Image(systemName: "sparkles") - .font(.system(size: 42, weight: .light)) - .foregroundStyle(OmoTheme.primary) - .symbolEffect(.breathe, options: .repeating) - Text("一段记忆正在苏醒") - .font(.title3.bold()) - Text("掌握 · \(card.masteryTitle)") - .font(.caption) - .foregroundStyle(OmoTheme.muted) - } - .frame(width: 238, height: 318) - .background(OmoTheme.surface, in: RoundedRectangle(cornerRadius: 26)) - .shadow(color: OmoTheme.primary.opacity(0.25), radius: 16, y: 10) - .transition(.offset(y: 130).combined(with: .scale(scale: 0.55)).combined(with: .opacity)) - - if summonStage == .revealed { - OmoSparkBurst(trigger: 1, tint: rarityColor) - } - } - - summonMascot - .frame(width: 112, height: 112) - .offset(summonMascotOffset) - .transition(.opacity) - } - .frame(height: 390) - .animation(reduceMotion ? .none : .spring(response: 0.42, dampingFraction: 0.76), value: summonStage) - - Button("跳过过场", action: finishSummon) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(OmoTheme.muted) - .frame(minWidth: 88, minHeight: 44) - } - .padding(.horizontal, OmoTheme.pageInset) - .foregroundStyle(OmoTheme.ink) - } - - @ViewBuilder - private var summonMascot: some View { - switch summonStage { - case .running: - OmoAtlasPlayer(asset: "OmoMotionRunAtlas", poster: "OmoMotionRunPoster", columns: 6, rows: 6, frameCount: 32) - case .rummaging: - OmoAtlasPlayer(asset: "OmoMotionRummageAtlas", poster: "OmoMotionRummagePoster", columns: 6, rows: 6, frameCount: 32) - case .carrying: - OmoAtlasPlayer(asset: "OmoMotionCarryReturnAtlas", poster: "OmoMotionCarryReturnPoster", columns: 6, rows: 2, frameCount: 10, loop: false) - case .landing, .revealed: - Image("OmoPoseApprove").resizable().scaledToFit() - } - } - - private var recallView: some View { - VStack(spacing: 16) { - HStack { - Button { dismiss() } label: { - Image(systemName: "xmark").frame(width: 44, height: 44) - } - Spacer() - Text("唤醒一张记忆").font(.headline) - Spacer() - Color.clear.frame(width: 44, height: 44) - } - - ScrollView { - VStack(alignment: .leading, spacing: 18) { - HStack { - RarityBadge(value: card.rarity) - Spacer() - Text("掌握 · \(card.masteryTitle)") - .font(.caption.weight(.semibold)) - .foregroundStyle(OmoTheme.primary) - } - Text("先别看答案") - .font(.caption.weight(.semibold)) - .foregroundStyle(OmoTheme.muted) - Text(card.recallCue) - .font(.title2.bold()) - .foregroundStyle(OmoTheme.ink) - .fixedSize(horizontal: false, vertical: true) - - if phase == .recall { - ScratchRevealCanvas(answer: card.answer, progress: $scratchProgress) { - reveal() - } - Button("直接揭晓", action: reveal) - .frame(maxWidth: .infinity, minHeight: 44) - .foregroundStyle(OmoTheme.muted) + if AIProcessingConsent.requiresPrompt(hasConsent: allowsAIProcessing) { + uploadCoordinator.receive(data, hasConsent: false) + showsAIConsent = true } else { - VStack(alignment: .leading, spacing: 12) { - Divider() - Text(card.coreKnowledge) - .font(.title3.bold()) - .foregroundStyle(OmoTheme.primary) - Text(card.answer).font(.body.weight(.semibold)) - Text(card.explanation).foregroundStyle(OmoTheme.muted) - sourceEvidence - } - .transition(.move(edge: .bottom).combined(with: .opacity)) + await submit(data, hasConsent: true) } } - .padding(22) - .background(OmoTheme.surface, in: RoundedRectangle(cornerRadius: 24)) - .shadow(color: OmoTheme.primary.opacity(0.15), radius: 10, y: 6) - .rotation3DEffect(.degrees(contentEntered ? 0 : 12), axis: (x: 1, y: 0, z: 0), anchor: .bottom) - .offset(y: contentEntered ? 0 : 34) - .opacity(contentEntered ? 1 : 0) - .overlay { - if phase == .revealed { - OmoSparkBurst(trigger: reactionTrigger, tint: OmoTheme.primary) - } + } + .alert("允许 AI 处理这张截图?", isPresented: $showsAIConsent) { + Button("取消", role: .cancel) { + uploadCoordinator.cancelConsent() + selection = nil } - - if phase == .revealed { - VStack(spacing: 12) { - Text("刚才想起来了吗?") - .foregroundStyle(OmoTheme.muted) - HStack(spacing: 8) { - ForEach(MemoryAssessment.allCases) { value in - AssessmentButton(value: value) { submit(value) } - } + Button("同意并生成") { + allowsAIProcessing = true + Task { + let accepted = await uploadCoordinator.confirmConsent { data in + await store.createCard(from: data) } + if accepted { dismiss() } + selection = nil } - .padding(.top, 18) - .transition(.move(edge: .bottom).combined(with: .opacity)) } + } message: { + Text("截图会经 Omo 的测试服务发送给第三方 AI,用于识别内容并生成记忆卡。请不要上传含敏感个人信息的截图。") } - } - .padding(.horizontal, OmoTheme.pageInset) - .foregroundStyle(OmoTheme.ink) - .onAppear { - withAnimation(reduceMotion ? .none : .spring(response: 0.55, dampingFraction: 0.8)) { - contentEntered = true + .onAppear { + withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { pulse = true } } } } - private var savingView: some View { - VStack(spacing: 22) { - ZStack { - if selectedAssessment != .remembered { - Image("OmoParticlePuff") - .resizable() - .renderingMode(.template) - .foregroundStyle(OmoTheme.muted.opacity(0.16)) - .frame(width: 230, height: 230) - } - Image(feedbackPose) - .resizable() - .scaledToFit() - .frame(width: 200, height: 200) - .symbolEffect(.bounce, options: .nonRepeating, value: reactionTrigger) - if selectedAssessment == .remembered { - OmoSparkBurst(trigger: reactionTrigger, tint: .yellow) - } - } - .frame(height: 230) - Text(error.isEmpty ? feedbackTitle : "结果还没有保存") - .font(.title2.bold()) - Text(feedbackDetail) - .foregroundStyle(OmoTheme.muted) - .multilineTextAlignment(.center) - if error.isEmpty { - ProgressView().tint(OmoTheme.primary) - } else { - Text(error).foregroundStyle(OmoTheme.muted) - PrimaryButton(title: "返回重试") { - phase = .revealed - error = "" - } - } + private func submit(_ data: Data, hasConsent: Bool) async { + uploadCoordinator.receive(data, hasConsent: hasConsent) + let accepted = await uploadCoordinator.submitReceived { image in + await store.createCard(from: image) } - .padding(OmoTheme.pageInset) - .foregroundStyle(OmoTheme.ink) + if accepted { dismiss() } + selection = nil } +} - private var completeView: some View { - VStack(spacing: 22) { - ZStack { - OmoOrbit().scaleEffect(0.8) - OmoSparkBurst(trigger: reactionTrigger + 1, tint: OmoTheme.primary) - OmoSparkBurst(trigger: reactionTrigger + 2, tint: .yellow).scaleEffect(0.72) - Image("OmoPoseHeart") - .resizable() - .scaledToFit() - .frame(width: 220, height: 220) - .symbolEffect(.breathe, options: .repeating) +private struct OmoPrivacyView: View { + var body: some View { + List { + Section("截图与 AI") { + Text("只有你主动选择的截图才会上传。Omo 测试服务会临时保存压缩截图以完成可恢复的 AI 处理任务,并在任务成功或失败后删除服务端副本。设备会在成功后删除本地重试副本;失败时保留该副本供你重试。") } - .frame(height: 250) - Text("记忆已经收好了") - .font(.title.bold()) - Text("掌握 · \(card.masteryTitle)") - .foregroundStyle(OmoTheme.primary) - Text(card.nextReviewText) - .foregroundStyle(OmoTheme.muted) - PrimaryButton(title: "完成", systemImage: "checkmark") { dismiss() } - } - .padding(OmoTheme.pageInset) - .foregroundStyle(OmoTheme.ink) - } - - private func reveal() { - guard phase == .recall else { return } - withAnimation(reduceMotion ? .none : .spring(response: 0.4, dampingFraction: 0.82)) { - scratchProgress = 1 - phase = .revealed - } - reactionTrigger += 1 - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - - private func submit(_ assessment: MemoryAssessment) { - selectedAssessment = assessment - reactionTrigger += 1 - withAnimation(.spring(response: 0.38, dampingFraction: 0.76)) { phase = .saving } - UINotificationFeedbackGenerator().notificationOccurred(assessment == .remembered ? .success : .warning) - Task { - do { - card = try await store.assess(card, as: assessment) - try? await Task.sleep(for: .milliseconds(reduceMotion ? 180 : 900)) - withAnimation(.spring(response: 0.5, dampingFraction: 0.74)) { phase = .complete } - } catch { - self.error = error.localizedDescription + Section("保存的数据") { + Text("Omo 使用随机生成的匿名设备标识区分数据,并保存生成后的记忆卡、来源信息、自评结果和复习时间。") } - } - } - - private var summonCaption: String { - switch summonStage { - case .running: "Omo 正赶去旧收藏里" - case .rummaging: "翻一翻,应该就在这里" - case .carrying: "找到了,正在带回来" - case .landing: "记忆正在落到你手里" - case .revealed: "准备好了吗?" - } - } - - private var summonMascotOffset: CGSize { - switch summonStage { - case .running: CGSize(width: 124, height: 130) - case .rummaging: CGSize(width: -86, height: 118) - case .carrying: CGSize(width: -8, height: 128) - case .landing, .revealed: CGSize(width: 126, height: 132) - } - } - - private var rarityColor: Color { - switch card.rarity { - case "SSR": .purple - case "SR": .orange - default: OmoTheme.primary - } - } - - private var feedbackPose: String { - switch selectedAssessment { - case .remembered: "OmoPoseApprove" - case .fuzzy: "OmoPoseConfused" - case .forgot: "OmoPoseDejected" - case nil: "OmoPoseDazed" - } - } - - private var feedbackTitle: String { - switch selectedAssessment { - case .remembered: "抓住了这段记忆" - case .fuzzy: "轮廓已经回来了" - case .forgot: "没关系,下次再见" - case nil: "正在收好这段记忆" - } - } - - private var feedbackDetail: String { - switch selectedAssessment { - case .remembered: "Omo 会把它放远一点,等需要时再回来。" - case .fuzzy: "下一次会更快出现,让模糊慢慢变清楚。" - case .forgot: "它会更早回来,不让这段内容再次积灰。" - case nil: "正在更新下一次召回时间。" - } - } - - @ViewBuilder - private var sourceEvidence: some View { - if card.sourceIsVerified, - let value = card.sourceUrl, - let url = URL(string: value) { - Link(destination: url) { - Label("TickHub 已核验 · \(card.sourceTitle)", systemImage: "checkmark.seal.fill") - .font(.caption.weight(.semibold)) - .foregroundStyle(OmoTheme.primary) - .lineLimit(2) + Section("语音搜索") { + Text("语音由 Apple 的语音识别能力转成文字;搜索文字会发送给 Omo 测试服务和第三方 AI,用于返回相关卡片。") } - } else { - Label("仅依据截图 · \(card.sourceTitle)", systemImage: "photo") - .font(.caption) - .foregroundStyle(OmoTheme.muted) - .lineLimit(2) - } - } - - private func playSummon() async { - guard phase == .summoning else { return } - if reduceMotion { - try? await Task.sleep(for: .milliseconds(180)) - finishSummon() - return - } - guard await advance(after: 360, to: .rummaging) else { return } - guard await advance(after: 620, to: .carrying) else { return } - guard await advance(after: 520, to: .landing) else { return } - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - guard await advance(after: 500, to: .revealed) else { return } - try? await Task.sleep(for: .milliseconds(420)) - finishSummon() - #if DEBUG - if ProcessInfo.processInfo.arguments.contains("-OmoAutoDemo") { - try? await Task.sleep(for: .milliseconds(650)) - reveal() - try? await Task.sleep(for: .milliseconds(650)) - submit(.remembered) - } - #endif - } - - private func advance(after milliseconds: Int, to stage: MemorySummonStage) async -> Bool { - try? await Task.sleep(for: .milliseconds(milliseconds)) - guard !Task.isCancelled, phase == .summoning else { return false } - withAnimation(.spring(response: 0.4, dampingFraction: 0.76)) { summonStage = stage } - return true - } - - private func finishSummon() { - guard phase == .summoning else { return } - withAnimation(.easeOut(duration: reduceMotion ? 0.12 : 0.28)) { phase = .recall } - } -} - -private struct OmoTabBar: View { - @Binding var selection: OmoTab - @Namespace private var selectionAnimation - - var body: some View { - HStack(spacing: 0) { - ForEach(OmoTab.allCases) { tab in - Button { - selection = tab - } label: { - VStack(spacing: 4) { - Image(systemName: tab.symbol) - .font(.system(size: 22, weight: .semibold)) - Text(tab.title).font(.caption.weight(.semibold)) - } - .frame(maxWidth: .infinity, minHeight: 62) - .foregroundStyle(selection == tab ? OmoTheme.primary : OmoTheme.ink) - .background { - if selection == tab { - RoundedRectangle(cornerRadius: 20) - .fill(OmoTheme.background.opacity(0.78)) - .matchedGeometryEffect(id: "selected-tab", in: selectionAnimation) - } - } - .clipShape(RoundedRectangle(cornerRadius: 20)) - } - .buttonStyle(SpringPressStyle()) + Section("通知与追踪") { + Text("复习通知仅在设备本地安排。Omo 当前不包含广告 SDK,不进行跨 App 或网站追踪。") + } + Section("管理数据") { + Text("若要删除当前匿名设备标识关联的云端数据,请联系支持。") + Link("mingyuhan0814@gmail.com", destination: URL(string: "mailto:mingyuhan0814@gmail.com")!) } } - .padding(8) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28)) - .shadow(color: OmoTheme.primary.opacity(0.18), radius: 12, y: 6) + .navigationTitle("隐私说明") + .navigationBarTitleDisplayMode(.inline) } } + private struct RarityBadge: View { let value: String @@ -809,94 +355,6 @@ private struct RarityBadge: View { } } -private struct PrimaryButton: View { - let title: String - var systemImage: String? - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - if let systemImage { Image(systemName: systemImage) } - Text(title) - } - .font(.headline) - .foregroundStyle(.white) - .frame(maxWidth: .infinity, minHeight: 54) - .background(OmoTheme.primary, in: RoundedRectangle(cornerRadius: 16)) - .shadow(color: OmoTheme.primary.opacity(0.24), radius: 9, y: 5) - } - .buttonStyle(SpringPressStyle()) - } -} - -private struct AssessmentButton: View { - let value: MemoryAssessment - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 5) { - Image(systemName: symbol).font(.headline) - Text(value.title).font(.caption.weight(.semibold)) - } - .foregroundStyle(color) - .frame(maxWidth: .infinity, minHeight: 58) - .background(OmoTheme.surface, in: RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.45))) - } - .buttonStyle(SpringPressStyle()) - } - - private var symbol: String { - switch value { - case .remembered: "checkmark.circle.fill" - case .fuzzy: "circle.lefthalf.filled" - case .forgot: "arrow.counterclockwise" - } - } - - private var color: Color { - switch value { - case .remembered: OmoTheme.primary - case .fuzzy: .orange - case .forgot: OmoTheme.muted - } - } -} - -private struct BreathingCardDeck: View { - let active: Bool - - var body: some View { - ZStack { - ForEach(0..<3, id: \.self) { index in - RoundedRectangle(cornerRadius: 26) - .fill(OmoTheme.surface.opacity(0.56 + Double(index) * 0.17)) - .frame(width: 245, height: 310) - .rotationEffect(.degrees(Double(index - 1) * (active ? 6.5 : 4.5))) - .offset( - x: CGFloat(index - 1) * (active ? 15 : 11), - y: CGFloat(2 - index) * 8 + (active && index == 2 ? -8 : 2) - ) - .shadow(color: OmoTheme.primary.opacity(0.16), radius: active ? 13 : 8, y: 7) - } - Image("OmoPoseSmirk") - .resizable() - .scaledToFit() - .frame(width: 158, height: 158) - .offset(y: active ? -8 : 5) - .rotationEffect(.degrees(active ? 2 : -2)) - Image(systemName: "sparkles") - .foregroundStyle(OmoTheme.primary.opacity(0.75)) - .font(.title2) - .offset(x: 78, y: active ? -84 : -66) - .symbolEffect(.pulse, options: .repeating) - } - .frame(height: 340) - .accessibilityHidden(true) - } -} private struct OmoLaunchScene: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion diff --git a/Omo/Omo/Info.plist b/Omo/Omo/Info.plist new file mode 100644 index 0000000..5a0e629 --- /dev/null +++ b/Omo/Omo/Info.plist @@ -0,0 +1,10 @@ + + + + + ITSAppUsesNonExemptEncryption + + OmoAPIBaseURL + $(OMO_API_BASE_URL) + + diff --git a/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryDebugFixtures.swift b/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryDebugFixtures.swift new file mode 100644 index 0000000..d5fd1ae --- /dev/null +++ b/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryDebugFixtures.swift @@ -0,0 +1,142 @@ +import Foundation + +#if DEBUG || OMO_TESTING +struct KnowledgeLibraryDebugConfiguration { + let usesMockSearch: Bool + let searchMode: DebugMockKnowledgeLibrarySearcher.Mode + let speechTranscript: String? + let speechDenied: Bool + let initialQuery: String? + + static func current(arguments: [String] = ProcessInfo.processInfo.arguments) -> Self { + let mode: DebugMockKnowledgeLibrarySearcher.Mode + if arguments.contains("-OmoLibrarySearchFailure") { + mode = .failure + } else if arguments.contains("-OmoLibrarySearchNoResults") { + mode = .noResults + } else { + mode = .matching + } + return Self( + usesMockSearch: arguments.contains("-OmoLibraryMockSearch") + || arguments.contains("-OmoLibrarySearchFailure") + || arguments.contains("-OmoLibrarySearchNoResults") + || arguments.contains("-OmoLibraryFixture"), + searchMode: mode, + speechTranscript: value(after: "-OmoLibraryVoiceTranscript", in: arguments), + speechDenied: arguments.contains("-OmoLibrarySpeechDenied"), + initialQuery: value(after: "-OmoLibraryQuery", in: arguments) + ) + } + + private static func value(after flag: String, in arguments: [String]) -> String? { + guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else { + return nil + } + return arguments[index + 1] + } +} + +enum KnowledgeLibraryDebugFixtures { + static let cards: [MemoryCard] = [ + make("library-01", "截图可能削弱记忆,因为它会触发认知卸载:用户认为设备已经替自己保存。", semantic: "认知卸载", rarity: "SSR"), + make("library-02", "提取练习比反复阅读更能暴露自己真正没有掌握的部分。", semantic: "提取练习", rarity: "SR"), + make("library-03", "先形成自己的答案,再查看解释,能减少熟悉感造成的掌握错觉。", semantic: "熟悉感", rarity: "R"), + make("library-04", "产品的 Magic Moment 应该尽早由用户自己的高价值内容触发,而不是由预设示例替代。", semantic: "用户自己的高价值内容", rarity: "SSR"), + make("library-05", "通知可以用一条具体知识向用户提问,在 App 外制造一次真实的“我到底还记不记得”。", semantic: "向用户提问", rarity: "SR"), + make("library-06", "信息被忘记时通常不痛,真正的痛发生在需要调用却只能想起模糊印象的时候。", semantic: "需要调用", rarity: "R"), + make("library-07", "低成本保存动作负责捕捉注意力,后续的二次唤醒流程负责把短暂印象变成可调用知识。", semantic: "二次唤醒", rarity: "SR"), + make("library-08", "来源恢复失败时仍可基于截图生成最低证据等级的卡片,并邀请用户稍后补充链接升格。", semantic: nil, rarity: "R"), + make("library-09", "稀有度描述知识节点的核心潜力,只作为视觉装饰,不代表抽取概率或用户掌握程度。", semantic: "核心潜力", rarity: "SSR"), + make("library-10", "一次复习只突出一个主要动作:先回忆,揭示后再自评。", semantic: "一个主要动作", rarity: "SR"), + make("library-11", "知识库负责完整浏览和取回;主动回忆牌组才使用句内遮挡。", semantic: "主动回忆牌组", rarity: "R"), + make("library-12", "新的搜索请求必须使旧请求失效,避免较慢的旧结果覆盖用户刚刚提交的新意图。", semantic: "旧请求失效", rarity: "SR") + ] + + private static func make( + _ id: String, + _ knowledge: String, + semantic: String?, + rarity: String + ) -> MemoryCard { + MemoryCard( + id: id, + coreKnowledge: knowledge, + hiddenSemantic: semantic, + recallCue: "这条知识的关键机制是什么?", + answer: semantic ?? knowledge, + explanation: "这是为知识库界面验收创建的合成解释,不来自真实用户数据。", + sourceTitle: "Omo 合成验收资料", + sourceAccount: "Omo Test", + sourcePlatform: "fixture", + sourceUrl: nil, + sourceStatus: "screenshot_only", + sourceProvider: nil, + sourceConfidence: nil, + rarity: rarity, + createdAt: "2026-08-03T00:00:00Z", + masteryStage: "sealed", + nextReviewAt: "2026-08-03T00:00:00Z", + reviewCount: 0, + successfulRecallCount: 0, + lastAssessment: nil + ) + } +} + +extension OmoStore { + func applyKnowledgeLibraryDebugArguments(_ arguments: [String]) { + guard let index = arguments.firstIndex(of: "-OmoLibraryFixture"), + arguments.indices.contains(index + 1) else { return } + switch arguments[index + 1] { + case "many": + cards = KnowledgeLibraryDebugFixtures.cards + message = "" + case "single": + cards = Array(KnowledgeLibraryDebugFixtures.cards.prefix(1)) + message = "" + case "empty": + cards = [] + message = "" + default: + break + } + } +} +#endif + +@MainActor +enum KnowledgeLibraryDependencies { + static func makeSearcher( + arguments: [String] = ProcessInfo.processInfo.arguments + ) -> any KnowledgeLibrarySearching { + #if DEBUG || OMO_TESTING + let configuration = KnowledgeLibraryDebugConfiguration.current(arguments: arguments) + if configuration.usesMockSearch { + return DebugMockKnowledgeLibrarySearcher(mode: configuration.searchMode) + } + #endif + return APIKnowledgeLibrarySearcher() + } + + static func makeSpeechTranscriber() -> any KnowledgeLibrarySpeechTranscribing { + #if DEBUG || OMO_TESTING + let configuration = KnowledgeLibraryDebugConfiguration.current() + if configuration.speechTranscript != nil || configuration.speechDenied { + return DebugKnowledgeLibrarySpeechTranscriber( + transcript: configuration.speechTranscript, + denied: configuration.speechDenied + ) + } + #endif + return AppleKnowledgeLibrarySpeechTranscriber() + } + + static var initialQuery: String? { + #if DEBUG || OMO_TESTING + KnowledgeLibraryDebugConfiguration.current().initialQuery + #else + nil + #endif + } +} diff --git a/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryPagination.swift b/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryPagination.swift new file mode 100644 index 0000000..6faa622 --- /dev/null +++ b/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryPagination.swift @@ -0,0 +1,83 @@ +import CoreGraphics + +struct KnowledgeLibraryPage: Equatable, Sendable { + struct Placement: Equatable, Sendable { + let id: ID + let column: Int + let y: CGFloat + let height: CGFloat + let sourceIndex: Int + } + + let placements: [Placement] + + var readingOrder: [ID] { + placements + .sorted { $0.sourceIndex < $1.sourceIndex } + .map(\.id) + } +} + +struct KnowledgeLibraryPaginator { + func pages( + itemHeights: [(ID, CGFloat)], + availableHeight: CGFloat, + verticalSpacing: CGFloat, + columnCount: Int = 2 + ) -> [KnowledgeLibraryPage] { + guard !itemHeights.isEmpty else { return [] } + let usableHeight = max(0, availableHeight) + let spacing = max(0, verticalSpacing) + var result: [KnowledgeLibraryPage] = [] + var placements: [KnowledgeLibraryPage.Placement] = [] + let resolvedColumnCount = max(1, columnCount) + var columnHeights = Array(repeating: CGFloat.zero, count: resolvedColumnCount) + + func completedPage() -> KnowledgeLibraryPage? { + guard !placements.isEmpty else { return nil } + return KnowledgeLibraryPage(placements: placements) + } + + func resetPage() { + placements = [] + columnHeights = Array(repeating: CGFloat.zero, count: resolvedColumnCount) + } + + for (sourceIndex, element) in itemHeights.enumerated() { + let (id, rawHeight) = element + let height = max(0, rawHeight) + + if !placements.isEmpty, columnHeights.max() ?? 0 > usableHeight { + if let page = completedPage() { result.append(page) } + resetPage() + } + + var column = columnHeights.indices.min { + columnHeights[$0] < columnHeights[$1] + } ?? 0 + var y = columnHeights[column] == 0 ? 0 : columnHeights[column] + spacing + let wouldOverflow = y + height > usableHeight + + if wouldOverflow, !placements.isEmpty { + if let page = completedPage() { result.append(page) } + resetPage() + column = 0 + y = 0 + } + + placements.append( + .init( + id: id, + column: column, + y: y, + height: height, + sourceIndex: sourceIndex + ) + ) + columnHeights[column] = y + height + } + + if let page = completedPage() { result.append(page) } + return result + } +} diff --git a/Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySearch.swift b/Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySearch.swift new file mode 100644 index 0000000..55cfc36 --- /dev/null +++ b/Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySearch.swift @@ -0,0 +1,138 @@ +import Foundation + +struct KnowledgeLibrarySearchDocument: Equatable, Sendable { + let id: String + let coreKnowledge: String + let recallCue: String + let explanation: String + let sourceTitle: String + + init(card: MemoryCard) { + id = card.id + coreKnowledge = card.coreKnowledge + recallCue = card.recallCue + explanation = card.explanation + sourceTitle = card.sourceTitle + } +} + +struct KnowledgeLibrarySearchRequest: Equatable, Sendable { + let query: String + let candidates: [KnowledgeLibrarySearchDocument] +} + +struct KnowledgeLibrarySearchResponse: Equatable, Sendable { + let orderedCardIDs: [String] +} + +protocol KnowledgeLibrarySearching: Sendable { + func search(_ request: KnowledgeLibrarySearchRequest) async throws -> KnowledgeLibrarySearchResponse +} + +enum KnowledgeLibrarySearchError: LocalizedError, Equatable, Sendable { + case unavailable + case failed(String) + + var errorDescription: String? { + switch self { + case .unavailable: + "暂时无法搜索,请稍后重试。" + case .failed(let message): + message + } + } +} + +struct UnavailableKnowledgeLibrarySearcher: KnowledgeLibrarySearching { + func search(_ request: KnowledgeLibrarySearchRequest) async throws -> KnowledgeLibrarySearchResponse { + throw KnowledgeLibrarySearchError.unavailable + } +} + +struct APIKnowledgeLibrarySearcher: KnowledgeLibrarySearching { + let client: APIClient + + init(client: APIClient = APIClient()) { + self.client = client + } + + func search(_ request: KnowledgeLibrarySearchRequest) async throws -> KnowledgeLibrarySearchResponse { + try await client.searchKnowledgeLibrary(query: request.query) + } +} + +#if DEBUG || OMO_TESTING +struct DebugMockKnowledgeLibrarySearcher: KnowledgeLibrarySearching { + enum Mode: Sendable { + case matching + case noResults + case failure + } + + let mode: Mode + let delay: Duration + + init(mode: Mode = .matching, delay: Duration = .milliseconds(220)) { + self.mode = mode + self.delay = delay + } + + func search(_ request: KnowledgeLibrarySearchRequest) async throws -> KnowledgeLibrarySearchResponse { + try await Task.sleep(for: delay) + switch mode { + case .noResults: + return KnowledgeLibrarySearchResponse(orderedCardIDs: []) + case .failure: + throw KnowledgeLibrarySearchError.unavailable + case .matching: + break + } + + let query = Self.normalized(request.query) + guard !query.isEmpty else { + return KnowledgeLibrarySearchResponse(orderedCardIDs: request.candidates.map(\.id)) + } + + let queryTokens = Self.tokens(query) + let ranked = request.candidates.compactMap { document -> (String, Int)? in + let fields = [ + (document.coreKnowledge, 8), + (document.recallCue, 5), + (document.explanation, 3), + (document.sourceTitle, 1) + ] + var score = 0 + for (field, weight) in fields { + let normalized = Self.normalized(field) + if normalized.contains(query) { score += 30 * weight } + let overlap = queryTokens.intersection(Self.tokens(normalized)).count + score += overlap * weight + } + return score > 0 ? (document.id, score) : nil + } + .sorted { lhs, rhs in + lhs.1 == rhs.1 ? lhs.0 < rhs.0 : lhs.1 > rhs.1 + } + + return KnowledgeLibrarySearchResponse(orderedCardIDs: ranked.map(\.0)) + } + + private static func normalized(_ value: String) -> String { + value + .lowercased() + .components(separatedBy: .whitespacesAndNewlines) + .joined() + .filter { $0.isLetter || $0.isNumber } + } + + private static func tokens(_ value: String) -> Set { + let characters = Array(value) + var values = Set(characters.map(String.init)) + guard characters.count > 1 else { return values } + for index in 0..<(characters.count - 1) { + values.insert(String(characters[index...index + 1])) + } + return values + } +} +#endif diff --git a/Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySpeech.swift b/Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySpeech.swift new file mode 100644 index 0000000..722afb7 --- /dev/null +++ b/Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySpeech.swift @@ -0,0 +1,179 @@ +import AVFAudio +import Foundation +import Speech + +enum KnowledgeLibrarySpeechEvent: Equatable, Sendable { + case listening + case transcript(String, isFinal: Bool) + case denied + case unavailable + case failed(String) + case stopped +} + +enum KnowledgeLibrarySpeechState: Equatable { + case idle + case listening + case denied + case unavailable + case failed(String) +} + +@MainActor +protocol KnowledgeLibrarySpeechTranscribing: AnyObject { + var events: AsyncStream { get } + func start() async + func stop() +} + +@MainActor +final class AppleKnowledgeLibrarySpeechTranscriber: KnowledgeLibrarySpeechTranscribing { + let events: AsyncStream + + private let recognizer: SFSpeechRecognizer? + private let audioEngine = AVAudioEngine() + private var eventContinuation: AsyncStream.Continuation? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var inputTapInstalled = false + + init(locale: Locale = Locale(identifier: "zh-CN")) { + recognizer = SFSpeechRecognizer(locale: locale) + var continuation: AsyncStream.Continuation? + events = AsyncStream { continuation = $0 } + eventContinuation = continuation + } + + deinit { + recognitionTask?.cancel() + if inputTapInstalled { audioEngine.inputNode.removeTap(onBus: 0) } + } + + func start() async { + guard !audioEngine.isRunning else { return } + guard await requestPermissions() else { + eventContinuation?.yield(.denied) + return + } + guard let recognizer, recognizer.isAvailable else { + eventContinuation?.yield(.unavailable) + return + } + + do { + try beginRecognition(using: recognizer) + eventContinuation?.yield(.listening) + } catch { + stopAudio(yieldStopped: false) + eventContinuation?.yield(.failed("语音输入启动失败,请重试。")) + } + } + + func stop() { + stopAudio(yieldStopped: true) + } + + private func requestPermissions() async -> Bool { + async let speech = withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status == .authorized) + } + } + async let microphone = withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + let speechAllowed = await speech + let microphoneAllowed = await microphone + return speechAllowed && microphoneAllowed + } + + private func beginRecognition(using recognizer: SFSpeechRecognizer) throws { + stopAudio(yieldStopped: false) + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + recognitionRequest = request + + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers]) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + + let inputNode = audioEngine.inputNode + let format = inputNode.outputFormat(forBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in + request.append(buffer) + } + inputTapInstalled = true + audioEngine.prepare() + try audioEngine.start() + + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + Task { @MainActor in + guard let self else { return } + if let result { + let value = result.bestTranscription.formattedString + self.eventContinuation?.yield(.transcript(value, isFinal: result.isFinal)) + if result.isFinal { self.stopAudio(yieldStopped: false) } + } else if error != nil { + self.stopAudio(yieldStopped: false) + self.eventContinuation?.yield(.failed("没有听清,请重试或改用文字输入。")) + } + } + } + } + + private func stopAudio(yieldStopped: Bool) { + if audioEngine.isRunning { audioEngine.stop() } + if inputTapInstalled { + audioEngine.inputNode.removeTap(onBus: 0) + inputTapInstalled = false + } + recognitionRequest?.endAudio() + recognitionRequest = nil + recognitionTask?.cancel() + recognitionTask = nil + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + if yieldStopped { eventContinuation?.yield(.stopped) } + } +} + +#if DEBUG || OMO_TESTING +@MainActor +final class DebugKnowledgeLibrarySpeechTranscriber: KnowledgeLibrarySpeechTranscribing { + let events: AsyncStream + private let transcript: String? + private let denied: Bool + private var eventContinuation: AsyncStream.Continuation? + private var task: Task? + + init(transcript: String?, denied: Bool = false) { + self.transcript = transcript + self.denied = denied + var continuation: AsyncStream.Continuation? + events = AsyncStream { continuation = $0 } + eventContinuation = continuation + } + + func start() async { + task?.cancel() + guard !denied else { + eventContinuation?.yield(.denied) + return + } + eventContinuation?.yield(.listening) + guard let transcript else { return } + task = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(380)) + guard !Task.isCancelled else { return } + self?.eventContinuation?.yield(.transcript(transcript, isFinal: true)) + } + } + + func stop() { + task?.cancel() + task = nil + eventContinuation?.yield(.stopped) + } +} +#endif diff --git a/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryView.swift b/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryView.swift new file mode 100644 index 0000000..3c423e6 --- /dev/null +++ b/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryView.swift @@ -0,0 +1,768 @@ +import SwiftUI +import UIKit + +struct KnowledgeLibraryView: View { + let cards: [MemoryCard] + let screenshotJobs: [ScreenshotJob] + let onBack: () -> Void + let onAdd: () -> Void + let onRetryJob: (ScreenshotJob) -> Void + let onOpenCard: (MemoryCard) -> Void + + @StateObject private var model: KnowledgeLibraryViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var appliedInitialQuery = false + + @MainActor + init( + cards: [MemoryCard], + screenshotJobs: [ScreenshotJob] = [], + onBack: @escaping () -> Void, + onAdd: @escaping () -> Void, + onRetryJob: @escaping (ScreenshotJob) -> Void = { _ in }, + onOpenCard: @escaping (MemoryCard) -> Void + ) { + self.init( + cards: cards, + screenshotJobs: screenshotJobs, + searcher: KnowledgeLibraryDependencies.makeSearcher(), + speechTranscriber: KnowledgeLibraryDependencies.makeSpeechTranscriber(), + onBack: onBack, + onAdd: onAdd, + onRetryJob: onRetryJob, + onOpenCard: onOpenCard + ) + } + + @MainActor + init( + cards: [MemoryCard], + screenshotJobs: [ScreenshotJob] = [], + searcher: any KnowledgeLibrarySearching, + speechTranscriber: any KnowledgeLibrarySpeechTranscribing, + onBack: @escaping () -> Void, + onAdd: @escaping () -> Void, + onRetryJob: @escaping (ScreenshotJob) -> Void = { _ in }, + onOpenCard: @escaping (MemoryCard) -> Void + ) { + self.cards = cards + self.screenshotJobs = screenshotJobs + self.onBack = onBack + self.onAdd = onAdd + self.onRetryJob = onRetryJob + self.onOpenCard = onOpenCard + _model = StateObject( + wrappedValue: KnowledgeLibraryViewModel( + cards: cards, + searcher: searcher, + speechTranscriber: speechTranscriber + ) + ) + } + + var body: some View { + GeometryReader { geometry in + let scale = KnowledgeLibraryMetrics.scale(for: geometry.size) + let canvasSize = CGSize( + width: KnowledgeLibraryMetrics.referenceSize.width * scale, + height: KnowledgeLibraryMetrics.referenceSize.height * scale + ) + ZStack { + RecallPalette.background.ignoresSafeArea() + referenceCanvas + .frame( + width: KnowledgeLibraryMetrics.referenceSize.width, + height: KnowledgeLibraryMetrics.referenceSize.height, + alignment: .topLeading + ) + .scaleEffect(scale) + .frame(width: canvasSize.width, height: canvasSize.height) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .ignoresSafeArea() + .onChange(of: cards) { _, newCards in model.updateCards(newCards) } + .task { + guard !appliedInitialQuery, let initialQuery = KnowledgeLibraryDependencies.initialQuery else { return } + appliedInitialQuery = true + model.query = initialQuery + model.submit() + } + .onDisappear { model.onDisappear() } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.state) + } + + private var referenceCanvas: some View { + ZStack(alignment: .topLeading) { + KnowledgeLibraryPanelShape() + .fill(RecallPalette.panel) + .frame( + width: KnowledgeLibraryMetrics.panelFrame.width, + height: KnowledgeLibraryMetrics.panelFrame.height + ) + .position( + x: KnowledgeLibraryMetrics.panelFrame.midX, + y: KnowledgeLibraryMetrics.panelFrame.midY + ) + .accessibilityHidden(true) + + Button(action: onBack) { + Image("KnowledgeLibraryBack") + .resizable() + .frame( + width: KnowledgeLibraryMetrics.backFrame.width, + height: KnowledgeLibraryMetrics.backFrame.height + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .position( + x: KnowledgeLibraryMetrics.backFrame.midX, + y: KnowledgeLibraryMetrics.backFrame.midY + ) + .accessibilityLabel("返回首页") + + Image("OmoPoseStretch") + .resizable() + .scaledToFit() + .frame( + width: KnowledgeLibraryMetrics.mascotFrame.width, + height: KnowledgeLibraryMetrics.mascotFrame.height + ) + .position( + x: KnowledgeLibraryMetrics.mascotFrame.midX, + y: KnowledgeLibraryMetrics.mascotFrame.midY + ) + .accessibilityHidden(true) + + KnowledgeLibrarySearchBar(model: model, isDisabled: cards.isEmpty) + .frame( + width: KnowledgeLibraryMetrics.searchFrame.width, + height: KnowledgeLibraryMetrics.searchFrame.height + ) + .position( + x: KnowledgeLibraryMetrics.searchFrame.midX, + y: KnowledgeLibraryMetrics.searchFrame.midY + ) + .zIndex(4) + + content + .frame( + width: KnowledgeLibraryMetrics.pagerFrame.width, + height: KnowledgeLibraryMetrics.pagerFrame.height + ) + .position( + x: KnowledgeLibraryMetrics.pagerFrame.midX, + y: KnowledgeLibraryMetrics.pagerFrame.midY + ) + + Image("FirstLaunchFolder") + .resizable() + .scaledToFit() + .frame( + width: KnowledgeLibraryMetrics.folderFrame.width, + height: KnowledgeLibraryMetrics.folderFrame.height + ) + .position( + x: KnowledgeLibraryMetrics.folderFrame.midX, + y: KnowledgeLibraryMetrics.folderFrame.midY + ) + .accessibilityHidden(true) + + Button(action: onAdd) { + Image("FirstLaunchUpload") + .resizable() + .frame( + width: KnowledgeLibraryMetrics.uploadFrame.width, + height: KnowledgeLibraryMetrics.uploadFrame.height + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .position( + x: KnowledgeLibraryMetrics.uploadFrame.midX, + y: KnowledgeLibraryMetrics.uploadFrame.midY + ) + .accessibilityLabel("上传新的知识截屏") + .accessibilityHint("打开截图上传流程") + } + } + + @ViewBuilder + private var content: some View { + ZStack(alignment: .top) { + Group { + if cards.isEmpty { + KnowledgeLibraryStateView( + title: screenshotJobs.contains(where: \.isActive) + ? "第一张知识卡正在整理" + : "还没有知识卡", + message: screenshotJobs.contains(where: \.isActive) + ? "你可以继续上传,处理完成后卡片会出现在这里。" + : "从相册选择一张有价值的截图,哦莫会替你整理好。", + actionTitle: "继续上传截图", + action: onAdd + ) + } else { + libraryCardsContent + } + } + + if let failed = screenshotJobs.first(where: { $0.canRetry }) { + KnowledgeLibraryJobBanner( + title: "一张截图整理失败", + showsProgress: false, + actionTitle: "重试", + action: { onRetryJob(failed) } + ) + } else if !screenshotJobs.filter(\.isActive).isEmpty { + KnowledgeLibraryJobBanner( + title: "正在整理 \(screenshotJobs.filter(\.isActive).count) 张截图", + showsProgress: true, + actionTitle: nil, + action: {} + ) + } + } + } + + @ViewBuilder + private var libraryCardsContent: some View { + switch model.state { + case .all, .results: + KnowledgeLibraryPager( + cards: model.visibleCards, + currentPage: $model.currentPage, + onOpenCard: onOpenCard + ) + case .searching: + VStack(spacing: 14) { + ProgressView().tint(RecallPalette.teal) + Text("正在帮你找") + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(RecallPalette.teal) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("正在搜索知识库") + case .noResults: + KnowledgeLibraryStateView( + title: "没有找到相关卡片", + message: "换一种描述,或者先回到全部卡片看看。", + actionTitle: "查看全部", + action: model.clearQuery + ) + case .failed(let message): + KnowledgeLibraryStateView( + title: "这次没找到", + message: message, + actionTitle: "重试", + action: model.retry + ) + } + } +} + +private struct KnowledgeLibraryJobBanner: View { + let title: String + let showsProgress: Bool + let actionTitle: String? + let action: () -> Void + + var body: some View { + HStack(spacing: 10) { + if showsProgress { + ProgressView().tint(RecallPalette.teal) + } + Text(title) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(RecallPalette.teal) + .lineLimit(1) + if let actionTitle { + Button(actionTitle, action: action) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(RecallPalette.coral) + .frame(minWidth: 44, minHeight: 44) + } + } + .padding(.leading, 14) + .padding(.trailing, actionTitle == nil ? 14 : 4) + .frame(minHeight: 44) + .background(RecallPalette.drawer, in: Capsule()) + .overlay(Capsule().stroke(RecallPalette.teal.opacity(0.45), lineWidth: 1)) + .shadow(color: RecallPalette.ink.opacity(0.12), radius: 4, y: 3) + .padding(.top, 4) + } +} + +private struct KnowledgeLibrarySearchBar: View { + @ObservedObject var model: KnowledgeLibraryViewModel + let isDisabled: Bool + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @FocusState private var isFocused: Bool + @State private var microphonePulse = false + @ScaledMetric(relativeTo: .body) private var searchFontSize: CGFloat = 14 + + private var isSearching: Bool { model.state == .searching } + private var canSubmit: Bool { + !isDisabled + && !isSearching + && !model.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + HStack(spacing: 5) { + TextField("描述你想找的知识", text: $model.query) + .font(.system(size: searchFontSize, weight: .medium)) + .foregroundStyle(RecallPalette.ink) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(false) + .submitLabel(.search) + .focused($isFocused) + .disabled(isDisabled || isSearching) + .onSubmit { if canSubmit { model.submit() } } + .accessibilityLabel("知识库搜索") + .accessibilityHint("描述你想找的知识,然后提交搜索") + + if !model.query.isEmpty && !isSearching { + Button { + model.clearQuery() + isFocused = true + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(RecallPalette.teal.opacity(0.62)) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("清除搜索") + } + + Button { + UISelectionFeedbackGenerator().selectionChanged() + Task { await model.startOrStopVoice() } + } label: { + Image("KnowledgeLibraryMicrophone") + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundStyle(model.speechState == .listening ? RecallPalette.coral : RecallPalette.teal) + .frame(width: 32, height: 24) + .scaleEffect(model.speechState == .listening && microphonePulse && !reduceMotion ? 1.12 : 1) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isDisabled || isSearching) + .accessibilityLabel(model.speechState == .listening ? "停止语音输入" : "使用语音输入") + .accessibilityHint(speechHint) + .onChange(of: model.speechState) { _, state in + guard state == .listening, !reduceMotion else { + microphonePulse = false + return + } + withAnimation(.easeInOut(duration: 0.62).repeatForever(autoreverses: true)) { + microphonePulse = true + } + } + + Button { + isFocused = false + model.submit() + } label: { + Group { + if isSearching { + ProgressView() + .controlSize(.small) + .tint(RecallPalette.teal) + } else { + Text("帮我找") + .font(.system(size: 12, weight: .semibold)) + } + } + .foregroundStyle(RecallPalette.teal) + .frame(width: 68, height: 44) + .background(RecallPalette.teal.opacity(canSubmit ? 0.16 : 0.09), in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(!canSubmit) + .accessibilityLabel(isSearching ? "正在搜索" : "帮我找") + } + .padding(.leading, 18) + .padding(.trailing, 10) + .background(RecallPalette.drawer, in: RoundedRectangle(cornerRadius: KnowledgeLibraryMetrics.searchCornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: KnowledgeLibraryMetrics.searchCornerRadius, style: .continuous) + .stroke(model.speechState == .listening ? RecallPalette.coral : RecallPalette.teal, lineWidth: model.speechState == .listening ? 2 : 1) + ) + .shadow(color: Color.black.opacity(0.16), radius: 4, x: 2, y: 5) + .overlay(alignment: .bottomLeading) { speechNotice.offset(y: 31) } + } + + @ViewBuilder + private var speechNotice: some View { + switch model.speechState { + case .denied: + Button("请在设置中开启语音权限") { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(RecallPalette.error) + .frame(minHeight: 44) + case .unavailable: + Text("语音输入暂时不可用,可以继续打字搜索") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(RecallPalette.error) + .frame(minHeight: 44) + case .failed(let message): + Text(message) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(RecallPalette.error) + .frame(minHeight: 44) + case .idle, .listening: + EmptyView() + } + } + + private var speechHint: String { + switch model.speechState { + case .denied: "语音权限已关闭,点击后可以前往设置" + case .unavailable: "当前设备暂时无法使用语音输入" + case .failed: "上次语音输入失败,可以再次尝试" + case .idle: "点击后开始把语音转成搜索文字" + case .listening: "点击后停止监听,已有转写会保留" + } + } +} + +private struct KnowledgeLibraryPager: View { + let cards: [MemoryCard] + @Binding var currentPage: Int + let onOpenCard: (MemoryCard) -> Void + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @State private var measuredHeights: [String: CGFloat] = [:] + + var body: some View { + GeometryReader { geometry in + let contentWidth = geometry.size.width - 18 + let columnCount = dynamicTypeSize.isAccessibilitySize ? 1 : 2 + let columnWidth = columnCount == 1 + ? contentWidth - 14 + : (contentWidth - KnowledgeLibraryMetrics.columnSpacing) / 2 + let pagerHeight = geometry.size.height - 38 + let pages = makePages( + availableHeight: pagerHeight, + columnCount: columnCount + ) + + ZStack(alignment: .bottom) { + TabView(selection: $currentPage) { + ForEach(Array(pages.enumerated()), id: \.offset) { pageIndex, page in + pageView( + page, + cards: cards, + columnWidth: columnWidth, + pageWidth: contentWidth, + pageHeight: pagerHeight + ) + .tag(pageIndex) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(width: contentWidth, height: pagerHeight) + .position(x: geometry.size.width / 2, y: pagerHeight / 2) + + KnowledgeLibraryPageIndicator( + pageCount: pages.count, + currentPage: currentPage + ) + .frame(height: 32) + + } + .overlay(alignment: .topLeading) { + measurementLayer(columnWidth: columnWidth) + .hidden() + .accessibilityHidden(true) + .allowsHitTesting(false) + } + .onChange(of: cards.map(\.id)) { _, _ in + measuredHeights = [:] + currentPage = 0 + } + .onChange(of: dynamicTypeSize) { _, _ in + measuredHeights = [:] + currentPage = 0 + } + .onChange(of: pages.count) { _, count in + if currentPage >= count { currentPage = max(0, count - 1) } + } + } + } + + private func makePages( + availableHeight: CGFloat, + columnCount: Int + ) -> [KnowledgeLibraryPage] { + let heights = cards.map { card in + (card.id, (measuredHeights[card.id] ?? 200) + 8) + } + return KnowledgeLibraryPaginator().pages( + itemHeights: heights, + availableHeight: availableHeight, + verticalSpacing: KnowledgeLibraryMetrics.rowSpacing, + columnCount: columnCount + ) + } + + private func measurementLayer(columnWidth: CGFloat) -> some View { + ZStack { + ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in + KnowledgeLibraryCardView(card: card, index: index, action: {}) + .frame(width: columnWidth) + .fixedSize(horizontal: false, vertical: true) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: KnowledgeLibraryCardHeightPreferenceKey.self, + value: [card.id: proxy.size.height] + ) + } + ) + } + } + .frame(width: columnWidth) + .onPreferenceChange(KnowledgeLibraryCardHeightPreferenceKey.self) { values in + guard values != measuredHeights else { return } + measuredHeights = values + } + } + + private func pageView( + _ page: KnowledgeLibraryPage, + cards: [MemoryCard], + columnWidth: CGFloat, + pageWidth: CGFloat, + pageHeight: CGFloat + ) -> some View { + let cardsByID = Dictionary(cards.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let contentHeight = max( + pageHeight, + page.placements.map { $0.y + $0.height + 10 }.max() ?? pageHeight + ) + return ScrollView(.vertical) { + ZStack(alignment: .topLeading) { + ForEach(page.placements.sorted { $0.sourceIndex < $1.sourceIndex }, id: \.id) { placement in + if let card = cardsByID[placement.id] { + KnowledgeLibraryCardView( + card: card, + index: placement.sourceIndex, + action: { onOpenCard(card) } + ) + .frame(width: columnWidth) + .rotationEffect(.degrees(KnowledgeLibraryMetrics.cardRotationDegrees[placement.sourceIndex % KnowledgeLibraryMetrics.cardRotationDegrees.count])) + .offset( + x: placement.column == 0 ? 7 : 7 + columnWidth + KnowledgeLibraryMetrics.columnSpacing, + y: placement.y + 5 + ) + } + } + } + .frame(width: pageWidth, height: contentHeight, alignment: .topLeading) + } + .scrollIndicators(.hidden) + .accessibilityElement(children: .contain) + .accessibilityLabel("知识卡第 \(currentPage + 1) 页") + } +} + +private struct KnowledgeLibraryCardView: View { + let card: MemoryCard + let index: Int + let action: () -> Void + @ScaledMetric(relativeTo: .body) private var cardFontSize: CGFloat = 16 + + private var style: CardStyle { CardStyle(index: index) } + + var body: some View { + Button(action: action) { + knowledgeText + .font(.system(size: cardFontSize, weight: .medium, design: .rounded)) + .multilineTextAlignment(.leading) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, minHeight: 96, alignment: .leading) + .padding(KnowledgeLibraryMetrics.cardContentInset) + .background( + style.background, + in: RoundedRectangle( + cornerRadius: KnowledgeLibraryMetrics.cardCornerRadius, + style: .continuous + ) + ) + .overlay( + RoundedRectangle(cornerRadius: KnowledgeLibraryMetrics.cardCornerRadius, style: .continuous) + .stroke(style.border, lineWidth: 1) + ) + .shadow(color: rarityColor.opacity(0.26), radius: 5, x: 2, y: 5) + .contentShape(RoundedRectangle(cornerRadius: KnowledgeLibraryMetrics.cardCornerRadius)) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(card.rarity) 卡片,\(card.coreKnowledge),来源:\(card.sourceTitle)") + .accessibilityHint("打开完整知识上下文") + .accessibilityAddTraits(.isButton) + } + + private var knowledgeText: Text { + var value = AttributedString(card.coreKnowledge) + value.foregroundColor = style.text + if let semantic = card.knowledgeSegments?.semantic, + let range = value.range(of: semantic) { + value[range].font = .system(size: cardFontSize, weight: .bold, design: .rounded) + value[range].foregroundColor = style.semantic + } + return Text(value) + } + + private var rarityColor: Color { + switch card.rarity { + case "SSR": RecallPalette.coral + case "SR": RecallPalette.tealSoft + default: RecallPalette.teal + } + } + + private struct CardStyle { + let background: Color + let text: Color + let semantic: Color + let border: Color + + init(index: Int) { + switch index % 5 { + case 1: + background = RecallPalette.teal + text = RecallPalette.panel + semantic = Color.white + border = RecallPalette.teal.opacity(0.75) + case 4: + background = RecallPalette.coral + text = RecallPalette.panel + semantic = Color.white + border = RecallPalette.coral.opacity(0.75) + default: + background = RecallPalette.card + text = RecallPalette.ink + semantic = RecallPalette.coral + border = Color.white.opacity(0.7) + } + } + } +} + +private struct KnowledgeLibraryPageIndicator: View { + let pageCount: Int + let currentPage: Int + + private var visibleIndices: [Int] { + guard pageCount > 0 else { return [] } + if pageCount <= 7 { return Array(0.. Void + @ScaledMetric(relativeTo: .title3) private var titleFontSize: CGFloat = 20 + @ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = 14 + + var body: some View { + VStack(spacing: 14) { + Text(title) + .font(.system(size: titleFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(RecallPalette.ink) + Text(message) + .font(.system(size: bodyFontSize, weight: .medium)) + .foregroundStyle(RecallPalette.teal) + .multilineTextAlignment(.center) + .frame(maxWidth: 250) + Button(actionTitle, action: action) + .font(.system(size: bodyFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(RecallPalette.panel) + .frame(minWidth: 132, minHeight: 44) + .background(RecallPalette.teal, in: Capsule()) + } + .padding(24) + } +} + +private struct KnowledgeLibraryCardHeightPreferenceKey: PreferenceKey { + static var defaultValue: [String: CGFloat] = [:] + + static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +private struct KnowledgeLibraryPanelShape: Shape { + func path(in rect: CGRect) -> Path { + let x = rect.width / 376 + let y = rect.height / 588 + var path = Path() + path.move(to: CGPoint(x: 0, y: 94.07 * y)) + path.addCurve( + to: CGPoint(x: 91.12 * x, y: 0.04 * y), + control1: CGPoint(x: 0, y: 42.54 * y), + control2: CGPoint(x: 39.62 * x, y: -1.6 * y) + ) + path.addCurve( + to: CGPoint(x: 133.22 * x, y: 5.31 * y), + control1: CGPoint(x: 108.34 * x, y: 0.59 * y), + control2: CGPoint(x: 123.86 * x, y: 2.11 * y) + ) + path.addCurve( + to: CGPoint(x: 299.75 * x, y: 29.69 * y), + control1: CGPoint(x: 149.68 * x, y: 10.95 * y), + control2: CGPoint(x: 266.5 * x, y: 29.69 * y) + ) + path.addCurve( + to: CGPoint(x: 376 * x, y: 84.54 * y), + control1: CGPoint(x: 335.32 * x, y: 29.69 * y), + control2: CGPoint(x: 376 * x, y: 48.97 * y) + ) + path.addLine(to: CGPoint(x: 376 * x, y: 513 * y)) + path.addCurve( + to: CGPoint(x: 301 * x, y: 588 * y), + control1: CGPoint(x: 376 * x, y: 554.42 * y), + control2: CGPoint(x: 342.42 * x, y: 588 * y) + ) + path.addLine(to: CGPoint(x: 75 * x, y: 588 * y)) + path.addCurve( + to: CGPoint(x: 0, y: 513 * y), + control1: CGPoint(x: 33.58 * x, y: 588 * y), + control2: CGPoint(x: 0, y: 554.42 * y) + ) + path.closeSubpath() + return path + } +} diff --git a/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryViewModel.swift b/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryViewModel.swift new file mode 100644 index 0000000..2523d9b --- /dev/null +++ b/Omo/Omo/KnowledgeLibrary/KnowledgeLibraryViewModel.swift @@ -0,0 +1,201 @@ +import Foundation + +enum KnowledgeLibraryResultsState: Equatable { + case all + case searching + case results + case noResults + case failed(message: String) +} + +@MainActor +final class KnowledgeLibraryViewModel: ObservableObject { + @Published var query = "" + @Published private(set) var state: KnowledgeLibraryResultsState = .all + @Published private(set) var visibleCards: [MemoryCard] + @Published var currentPage = 0 + @Published private(set) var speechState: KnowledgeLibrarySpeechState = .idle + + private var allCards: [MemoryCard] + private let searcher: any KnowledgeLibrarySearching + private let speechTranscriber: (any KnowledgeLibrarySpeechTranscribing)? + private var searchTask: Task? + private var speechEventsTask: Task? + private var requestGeneration = UUID() + + init( + cards: [MemoryCard], + searcher: any KnowledgeLibrarySearching, + speechTranscriber: (any KnowledgeLibrarySpeechTranscribing)? = nil + ) { + allCards = cards + visibleCards = cards + self.searcher = searcher + self.speechTranscriber = speechTranscriber + observeSpeechEvents() + } + + deinit { + searchTask?.cancel() + speechEventsTask?.cancel() + } + + func updateCards(_ cards: [MemoryCard]) { + allCards = cards + switch state { + case .all: + visibleCards = cards + case .results: + let allowed = Dictionary(cards.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + visibleCards = visibleCards.compactMap { allowed[$0.id] } + if visibleCards.isEmpty { state = .noResults } + case .searching, .noResults, .failed: + break + } + currentPage = 0 + } + + func submit() { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + clearQuery() + return + } + + query = trimmed + cancelSearch() + let generation = UUID() + requestGeneration = generation + state = .searching + currentPage = 0 + let request = KnowledgeLibrarySearchRequest( + query: trimmed, + candidates: allCards.map(KnowledgeLibrarySearchDocument.init(card:)) + ) + + searchTask = Task { [weak self, searcher] in + do { + let response = try await searcher.search(request) + guard !Task.isCancelled else { return } + self?.apply(response, generation: generation) + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + self?.apply(error, generation: generation) + } + } + } + + func retry() { + submit() + } + + func clearQuery() { + cancelSearch() + query = "" + state = .all + visibleCards = allCards + currentPage = 0 + } + + func cancelSearch() { + searchTask?.cancel() + searchTask = nil + requestGeneration = UUID() + } + + func startOrStopVoice() async { + guard let speechTranscriber else { + speechState = .unavailable + return + } + if speechState == .listening { + speechTranscriber.stop() + } else { + await speechTranscriber.start() + } + } + + func onDisappear() { + cancelSearch() + speechTranscriber?.stop() + } + + #if DEBUG || OMO_TESTING + func waitForSearchForTesting() async { + // Voice events arrive through an AsyncStream. The test may ask to wait + // immediately after yielding a final transcript, before that event has + // created the search task on the main actor. + for _ in 0..<100 { + if let searchTask { + await searchTask.value + return + } + await Task.yield() + } + } + + func waitForSpeechStateForTesting(_ expectedState: KnowledgeLibrarySpeechState) async { + for _ in 0..<100 { + if speechState == expectedState { return } + await Task.yield() + } + } + #endif + + private func apply(_ response: KnowledgeLibrarySearchResponse, generation: UUID) { + guard generation == requestGeneration else { return } + let cardsByID = Dictionary(allCards.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var seen = Set() + visibleCards = response.orderedCardIDs.compactMap { id in + guard seen.insert(id).inserted else { return nil } + return cardsByID[id] + } + state = visibleCards.isEmpty ? .noResults : .results + currentPage = 0 + } + + private func apply(_ error: Error, generation: UUID) { + guard generation == requestGeneration else { return } + let message = (error as? LocalizedError)?.errorDescription + ?? KnowledgeLibrarySearchError.unavailable.errorDescription + ?? "暂时无法搜索,请稍后重试。" + visibleCards = [] + state = .failed(message: message) + currentPage = 0 + } + + private func observeSpeechEvents() { + guard let speechTranscriber else { return } + speechEventsTask = Task { [weak self] in + for await event in speechTranscriber.events { + guard !Task.isCancelled else { return } + self?.handleSpeechEvent(event) + } + } + } + + private func handleSpeechEvent(_ event: KnowledgeLibrarySpeechEvent) { + switch event { + case .listening: + speechState = .listening + case .transcript(let value, let isFinal): + query = value + if isFinal { + speechState = .idle + if !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + submit() + } + } + case .denied: + speechState = .denied + case .unavailable: + speechState = .unavailable + case .failed(let message): + speechState = .failed(message) + case .stopped: + speechState = .idle + } + } +} diff --git a/Omo/Omo/Models/OmoModels.swift b/Omo/Omo/Models/OmoModels.swift index c08aaff..0c4152b 100644 --- a/Omo/Omo/Models/OmoModels.swift +++ b/Omo/Omo/Models/OmoModels.swift @@ -40,9 +40,10 @@ enum MemoryAssessment: String, Codable, CaseIterable, Identifiable { } } -struct MemoryCard: Codable, Identifiable, Equatable { +struct MemoryCard: Codable, Identifiable, Equatable, Sendable { let id: String let coreKnowledge: String + let hiddenSemantic: String? let recallCue: String let answer: String let explanation: String @@ -84,6 +85,35 @@ struct MemoryCard: Codable, Identifiable, Equatable { } var sourceIsVerified: Bool { sourceStatus == "verified" && sourceUrl?.isEmpty == false } + + var knowledgeSegments: RecallKnowledgeSegments? { + RecallKnowledgeSegments.make( + coreKnowledge: coreKnowledge, + hiddenSemantic: hiddenSemantic + ) + } + + var isRecallEligible: Bool { knowledgeSegments != nil } +} + +struct RecallKnowledgeSegments: Equatable { + let prefix: String + let semantic: String + let suffix: String + + static func make(coreKnowledge: String, hiddenSemantic: String?) -> Self? { + let semantic = hiddenSemantic? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !semantic.isEmpty, + let range = coreKnowledge.range(of: semantic) else { + return nil + } + return Self( + prefix: String(coreKnowledge[.. Void - - @State private var paths: [[CGPoint]] = [] - @State private var covered: Set = [] - private let columns = 12 - private let rows = 6 - - var body: some View { - GeometryReader { geometry in - Canvas { context, size in - let text = context.resolve( - Text(answer).font(.body.weight(.semibold)).foregroundStyle(Color(red: 0.58, green: 0.65, blue: 0.27)) - ) - context.draw(text, in: CGRect(x: 18, y: 18, width: size.width - 36, height: size.height - 36)) - - context.drawLayer { layer in - let cover = Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 16) - layer.fill(cover, with: .color(Color(red: 0.90, green: 0.90, blue: 0.72))) - layer.blendMode = .destinationOut - for pathPoints in paths where pathPoints.count > 1 { - var path = Path() - path.move(to: rendered(pathPoints[0], in: size)) - for point in pathPoints.dropFirst() { path.addLine(to: rendered(point, in: size)) } - layer.stroke(path, with: .color(.black), style: StrokeStyle(lineWidth: 30, lineCap: .round, lineJoin: .round)) - } - } - } - .drawingGroup() - .overlay { - if progress < 0.12 { - Label("刮开涂层,找回这句话", systemImage: "hand.draw.fill") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Color(red: 0.49, green: 0.49, blue: 0.43)) - .allowsHitTesting(false) - } - } - .contentShape(RoundedRectangle(cornerRadius: 16)) - .gesture( - DragGesture(minimumDistance: 2) - .onChanged { value in - if paths.isEmpty || value.translation == .zero { paths.append([]) } - paths[paths.count - 1].append(normalized(value.location, in: geometry.size)) - mark(value.location, in: geometry.size) - } - ) - } - .frame(minHeight: 112) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .accessibilityElement(children: .ignore) - .accessibilityLabel("被遮住的答案") - .accessibilityValue("已刮开 \(Int(progress * 100))%") - .accessibilityAction(named: "完整揭晓", onReveal) - } - - private func normalized(_ point: CGPoint, in size: CGSize) -> CGPoint { - CGPoint(x: min(1, max(0, point.x / size.width)), y: min(1, max(0, point.y / size.height))) - } - - private func rendered(_ point: CGPoint, in size: CGSize) -> CGPoint { - CGPoint(x: point.x * size.width, y: point.y * size.height) - } - - private func mark(_ point: CGPoint, in size: CGSize) { - let column = min(columns - 1, max(0, Int(point.x / size.width * CGFloat(columns)))) - let row = min(rows - 1, max(0, Int(point.y / size.height * CGFloat(rows)))) - for x in max(0, column - 1)...min(columns - 1, column + 1) { - for y in max(0, row - 1)...min(rows - 1, row + 1) { covered.insert(y * columns + x) } - } - progress = CGFloat(covered.count) / CGFloat(columns * rows) - if progress >= 0.42 { onReveal() } - } -} struct SpringPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { diff --git a/Omo/Omo/OmoApp.swift b/Omo/Omo/OmoApp.swift index f266031..cd4c2ff 100644 --- a/Omo/Omo/OmoApp.swift +++ b/Omo/Omo/OmoApp.swift @@ -3,12 +3,17 @@ import SwiftUI @main struct OmoApp: App { @StateObject private var store = OmoStore() + @StateObject private var notificationRouter = RecallNotificationRouter() var body: some Scene { WindowGroup { ContentView() .environmentObject(store) .preferredColorScheme(.light) + .onReceive(notificationRouter.$cardID.compactMap { $0 }) { cardID in + store.handleRecallNotification(cardID: cardID) + notificationRouter.consume(cardID) + } } } } diff --git a/Omo/Omo/OmoStore.swift b/Omo/Omo/OmoStore.swift index cb7aaaf..1db7e36 100644 --- a/Omo/Omo/OmoStore.swift +++ b/Omo/Omo/OmoStore.swift @@ -1,35 +1,84 @@ +import CryptoKit import SwiftUI import UIKit +enum OmoLoadState: Equatable { + case idle + case loading + case loaded + case failed(String) +} + @MainActor final class OmoStore: ObservableObject { @Published var cards: [MemoryCard] = [] @Published var selectedTab: OmoTab = .today @Published var presentedCard: MemoryCard? @Published var pendingCard: MemoryCard? - @Published var isLoading = false - @Published var isCreating = false + @Published var notificationRecallCard: MemoryCard? + @Published private(set) var screenshotJobs: [ScreenshotJob] = [] + @Published private(set) var pendingRecallCardID: String? + @Published private(set) var loadState: OmoLoadState = .idle @Published var message = "" - private let api: APIClient + private let api: any OmoAPIProviding + private let notificationScheduler: any RecallNotificationScheduling + private let screenshotJobCache: any ScreenshotJobPersisting + private var screenshotPollingTasks: [String: Task] = [:] - init(api: APIClient = APIClient()) { + init( + api: any OmoAPIProviding = APIClient(), + notificationScheduler: any RecallNotificationScheduling = LocalRecallNotificationScheduler(), + screenshotJobCache: any ScreenshotJobPersisting = ScreenshotJobCache() + ) { self.api = api + self.notificationScheduler = notificationScheduler + self.screenshotJobCache = screenshotJobCache } var dueCards: [MemoryCard] { - cards.filter(\.isDue).sorted { $0.nextReviewAt < $1.nextReviewAt } + cards + .filter { $0.isDue && $0.isRecallEligible } + .sorted { $0.nextReviewAt < $1.nextReviewAt } + } + + var nextRecallDeck: [MemoryCard] { + Array(dueCards.prefix(10)) + } + + var activeScreenshotJobs: [ScreenshotJob] { + screenshotJobs.filter(\.isActive) + } + + var failedScreenshotJobs: [ScreenshotJob] { + screenshotJobs.filter { $0.state == .failed } } func load() async { - guard !isLoading else { return } - isLoading = true - defer { isLoading = false } + guard loadState != .loading else { return } + loadState = .loading + let cachedJobs = (try? await screenshotJobCache.loadJobs()) ?? [] + screenshotJobs = cachedJobs do { - cards = try await api.cards() + async let loadedCards = api.cards() + async let loadedJobs = api.screenshotJobs() + cards = try await loadedCards + let serverJobs = try await loadedJobs + mergeScreenshotJobs(serverJobs, cachedJobs: cachedJobs) + for job in serverJobs { + try? await screenshotJobCache.update(job: job) + if job.state == .succeeded { + try? await screenshotJobCache.removeImage(for: job.id) + } + } + await resumeLocalJobsMissingFromServer(serverJobIDs: Set(serverJobs.map(\.id))) + for job in screenshotJobs where job.isActive { startPolling(job.id) } + resolvePendingRecallNotification() message = "" + loadState = .loaded } catch { message = error.localizedDescription + loadState = .failed(error.localizedDescription) } } @@ -37,17 +86,80 @@ final class OmoStore: ObservableObject { presentedCard = dueCards.first } + func handleRecallNotification(cardID: String) { + selectedTab = .today + presentedCard = nil + if let card = cards.first(where: { $0.id == cardID && $0.isRecallEligible }) { + notificationRecallCard = card + pendingRecallCardID = nil + } else { + notificationRecallCard = nil + pendingRecallCardID = cardID + } + } + + func resolvePendingRecallNotification() { + guard let cardID = pendingRecallCardID else { return } + pendingRecallCardID = nil + guard let card = cards.first(where: { $0.id == cardID && $0.isRecallEligible }) else { + notificationRecallCard = nil + return + } + notificationRecallCard = card + } + func createCard(from data: Data) async -> Bool { - guard !isCreating else { return false } - isCreating = true - defer { isCreating = false } + let image: Data do { - let image = try Self.preparedImage(data) - let card = try await api.createCard(from: image) - upsert(card) + image = try Self.preparedImage(data) + } catch { + message = error.localizedDescription + return false + } + + let optimistic = Self.optimisticScreenshotJob(for: image) + upsert(optimistic) + do { + try await screenshotJobCache.save(job: optimistic, imageData: image) + let accepted = try await api.createScreenshotJob(from: image) + if accepted.id != optimistic.id { + try? await screenshotJobCache.removeJob(for: optimistic.id) + screenshotJobs.removeAll { $0.id == optimistic.id } + try await screenshotJobCache.save(job: accepted, imageData: image) + } else { + try await screenshotJobCache.update(job: accepted) + } + upsert(accepted) selectedTab = .today - pendingCard = card message = "" + startPolling(accepted.id) + return true + } catch { + var failed = optimistic + failed.state = .failed + failed.updatedAt = ISO8601DateFormatter().string(from: Date()) + failed.errorCode = "submission_failed" + failed.errorMessage = error.localizedDescription + failed.retryable = true + upsert(failed) + try? await screenshotJobCache.update(job: failed) + message = failed.errorMessage + return false + } + } + + func retryScreenshotJob(_ job: ScreenshotJob) async -> Bool { + guard job.canRetry, + let image = try? await screenshotJobCache.imageData(for: job.id) else { + message = "原截图已不可用,请重新选择截图。" + return false + } + do { + let accepted = try await api.retryScreenshotJob(id: job.id, imageData: image) + upsert(accepted) + try? await screenshotJobCache.update(job: accepted) + message = "" + startPolling(accepted.id) return true } catch { message = error.localizedDescription @@ -55,10 +167,39 @@ final class OmoStore: ObservableObject { } } + func refreshScreenshotJobs() async { + do { + let jobs = try await api.screenshotJobs() + mergeScreenshotJobs(jobs, cachedJobs: screenshotJobs) + for job in jobs { + try? await screenshotJobCache.update(job: job) + if job.state == .succeeded { + try? await screenshotJobCache.removeImage(for: job.id) + } else if job.isActive { + startPolling(job.id) + } + } + await refreshCardsAfterCompletedJobs(jobs) + } catch { + message = error.localizedDescription + } + } + func assess(_ card: MemoryCard, as assessment: MemoryAssessment) async throws -> MemoryCard { + #if DEBUG || OMO_TESTING + if Self.usesSuccessfulAssessmentFixture { + var updated = card + updated.nextReviewAt = "2100-01-01T00:00:00Z" + updated.reviewCount += 1 + updated.successfulRecallCount += assessment == .remembered ? 1 : 0 + updated.lastAssessment = assessment + upsert(updated) + return updated + } + #endif let updated = try await api.assess(card, as: assessment) upsert(updated) - presentedCard = updated + try? await notificationScheduler.schedule(updated) return updated } @@ -66,6 +207,7 @@ final class OmoStore: ObservableObject { do { try await api.delete(card) cards.removeAll { $0.id == card.id } + await notificationScheduler.cancel(cardID: card.id) message = "" } catch { message = error.localizedDescription @@ -80,6 +222,110 @@ final class OmoStore: ObservableObject { } } + private func upsert(_ job: ScreenshotJob) { + if let index = screenshotJobs.firstIndex(where: { $0.id == job.id }) { + screenshotJobs[index] = job + } else { + screenshotJobs.insert(job, at: 0) + } + } + + private func mergeScreenshotJobs( + _ serverJobs: [ScreenshotJob], + cachedJobs: [ScreenshotJob] + ) { + let serverByID = Dictionary( + serverJobs.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let localOnly = cachedJobs.filter { serverByID[$0.id] == nil } + screenshotJobs = (serverJobs + localOnly).sorted { $0.createdAt > $1.createdAt } + } + + private func resumeLocalJobsMissingFromServer(serverJobIDs: Set) async { + let localJobs = screenshotJobs.filter { + $0.isActive && !serverJobIDs.contains($0.id) + } + for job in localJobs { + guard let image = try? await screenshotJobCache.imageData(for: job.id) else { + continue + } + do { + let accepted = try await api.createScreenshotJob(from: image) + upsert(accepted) + try? await screenshotJobCache.update(job: accepted) + } catch { + var failed = job + failed.state = .failed + failed.updatedAt = ISO8601DateFormatter().string(from: Date()) + failed.errorCode = "submission_failed" + failed.errorMessage = error.localizedDescription + failed.retryable = true + upsert(failed) + try? await screenshotJobCache.update(job: failed) + } + } + } + + private func startPolling(_ jobID: String) { + guard screenshotPollingTasks[jobID] == nil else { return } + screenshotPollingTasks[jobID] = Task { [weak self] in + guard let self else { return } + defer { screenshotPollingTasks[jobID] = nil } + for attempt in 0..<600 { + if attempt > 0 { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + } + do { + let job = try await api.screenshotJob(id: jobID) + upsert(job) + try? await screenshotJobCache.update(job: job) + if job.state == .succeeded { + try? await screenshotJobCache.removeImage(for: job.id) + await refreshCardsAfterCompletedJobs([job]) + return + } + if job.state == .failed { return } + } catch { + if attempt == 599 { message = error.localizedDescription } + } + } + } + } + + private func refreshCardsAfterCompletedJobs(_ jobs: [ScreenshotJob]) async { + let completedIDs = Set( + jobs.filter { $0.state == .succeeded }.map(\.cardId).filter { !$0.isEmpty } + ) + guard !completedIDs.isEmpty else { return } + guard let refreshed = try? await api.cards() else { return } + let previousIDs = Set(cards.map(\.id)) + cards = refreshed + for card in refreshed where completedIDs.contains(card.id) && !previousIDs.contains(card.id) { + try? await notificationScheduler.schedule(card) + } + } + + private static func optimisticScreenshotJob(for image: Data) -> ScreenshotJob { + let base64 = image.base64EncodedString() + let digest = SHA256.hash(data: Data(base64.utf8)) + .map { String(format: "%02x", $0) } + .joined() + let now = ISO8601DateFormatter().string(from: Date()) + return ScreenshotJob( + id: "job-\(digest.prefix(20))", + state: .accepted, + createdAt: now, + updatedAt: now, + attemptCount: 0, + cardId: "", + errorCode: "", + errorMessage: "", + retryable: false + ) + } + private static func preparedImage(_ data: Data) throws -> Data { guard let image = UIImage(data: data) else { throw ImageError.invalid } let maximumEdge: CGFloat = 2048 @@ -93,6 +339,49 @@ final class OmoStore: ObservableObject { } return jpeg } + + #if DEBUG || OMO_TESTING + private static var usesSuccessfulAssessmentFixture: Bool { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: "-OmoAssessmentFixture"), + arguments.indices.contains(index + 1) else { return false } + return arguments[index + 1] == "success" + } + + func applyScreenshotJobDebugArguments(_ arguments: [String]) { + guard let index = arguments.firstIndex(of: "-OmoScreenshotJobFixture"), + arguments.indices.contains(index + 1) else { return } + let now = ISO8601DateFormatter().string(from: Date()) + switch arguments[index + 1] { + case "active": + screenshotJobs = [ScreenshotJob( + id: "job-ui-active", + state: .processing, + createdAt: now, + updatedAt: now, + attemptCount: 1, + cardId: "", + errorCode: "", + errorMessage: "", + retryable: false + )] + case "failed": + screenshotJobs = [ScreenshotJob( + id: "job-ui-failed", + state: .failed, + createdAt: now, + updatedAt: now, + attemptCount: 1, + cardId: "", + errorCode: "model_timeout", + errorMessage: "截图处理超时,请重试。", + retryable: true + )] + default: + break + } + } + #endif } private enum ImageError: LocalizedError { diff --git a/Omo/Omo/PrivacyInfo.xcprivacy b/Omo/Omo/PrivacyInfo.xcprivacy index 5704bed..1920f1d 100644 --- a/Omo/Omo/PrivacyInfo.xcprivacy +++ b/Omo/Omo/PrivacyInfo.xcprivacy @@ -7,7 +7,56 @@ NSPrivacyTrackingDomains NSPrivacyCollectedDataTypes - + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeUserID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherUserContent + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + NSPrivacyAccessedAPITypes diff --git a/Omo/Omo/ProfileView.swift b/Omo/Omo/ProfileView.swift index 14b2e84..4ee8b8f 100644 --- a/Omo/Omo/ProfileView.swift +++ b/Omo/Omo/ProfileView.swift @@ -19,6 +19,11 @@ struct ProfileView: View { // MARK: - Environment @EnvironmentObject private var store: OmoStore + var onBack: (() -> Void)? + + init(onBack: (() -> Void)? = nil) { + self.onBack = onBack + } // MARK: - 非 View 计算属性 @@ -60,9 +65,20 @@ struct ProfileView: View { private var header: some View { VStack(alignment: .leading, spacing: 10) { - Text("我的") - .font(.largeTitle.bold()) - .accessibilityAddTraits(.isHeader) + HStack(spacing: 8) { + if let onBack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("返回首页") + } + Text("我的") + .font(.largeTitle.bold()) + .accessibilityAddTraits(.isHeader) + Spacer() + } ProfileHairline() } } diff --git a/Omo/Omo/RecallDesign.swift b/Omo/Omo/RecallDesign.swift new file mode 100644 index 0000000..d801b24 --- /dev/null +++ b/Omo/Omo/RecallDesign.swift @@ -0,0 +1,77 @@ +import SwiftUI + +enum RecallPalette { + static let background = Color(red: 1.00, green: 0.61, blue: 0.43) + static let panel = Color(red: 0.99, green: 0.89, blue: 0.78) + static let card = Color(red: 0.99, green: 0.90, blue: 0.79) + static let teal = Color(red: 0.38, green: 0.53, blue: 0.53) + static let tealSoft = Color(red: 0.58, green: 0.72, blue: 0.71) + static let ink = Color(red: 0.18, green: 0.34, blue: 0.34) + static let coral = Color(red: 0.92, green: 0.42, blue: 0.27) + static let drawer = Color(red: 0.99, green: 0.91, blue: 0.81) + static let scrim = Color.black.opacity(0.26) + static let error = Color(red: 0.68, green: 0.22, blue: 0.18) +} + +enum RecallHomeMetrics { + static let referenceSize = CGSize(width: 402, height: 874) + static let menuFrame = CGRect(x: 25, y: 43, width: 70, height: 70) + static let mascotFrame = CGRect(x: 209, y: 105, width: 170, height: 170) + static let panelFrame = CGRect(x: 13, y: 265, width: 376, height: 588) + static let promptFrame = CGRect(x: 78, y: 476, width: 246, height: 54) + static let statusFrame = CGRect(x: 92, y: 534, width: 218, height: 48) + static let folderFrame = CGRect(x: 6, y: 623, width: 220, height: 220) + static let uploadFrame = CGRect(x: 296, y: 733, width: 70, height: 73) + static let uploadArrowFrame = CGRect(x: 249, y: 587, width: 90, height: 100) + static let mascotArrowFrame = CGRect(x: 193, y: 168, width: 90, height: 105) + static let cardStackFrame = CGRect(x: 70, y: 322, width: 262, height: 184) + static let ratingFrame = CGRect(x: 58, y: 536, width: 286, height: 82) + static let errorFrame = CGRect(x: 88, y: 622, width: 226, height: 44) + static let drawerMaxWidth: CGFloat = 286 + static let drawerWidthRatio: CGFloat = 0.76 + + static func scale(for size: CGSize) -> CGFloat { + min(1, min(size.width / referenceSize.width, size.height / referenceSize.height)) + } +} + +enum RecallCardMetrics { + static let cornerRadius: CGFloat = 18 + static let contentInset: CGFloat = 22 + static let semanticHeight: CGFloat = 36 + static let brushDiameter: CGFloat = 26 + static let visibleLayerCount = 4 +} + +enum RecallRatingMetrics { + static let trackWidth: CGFloat = 286 + static let trackHeight: CGFloat = 32 + static let knobSize = CGSize(width: 41.5, height: 36.2) + static let nodeDiameter: CGFloat = 6.5 + static let labelTop: CGFloat = 42 + static let totalHeight: CGFloat = 66 +} + +enum KnowledgeLibraryMetrics { + static let referenceSize = CGSize(width: 402, height: 874) + static let backFrame = CGRect(x: 21, y: 43, width: 70, height: 70) + static let mascotFrame = CGRect(x: 226, y: 32, width: 154, height: 154) + static let panelFrame = CGRect(x: 13, y: 180, width: 376, height: 674) + static let searchFrame = CGRect(x: 21, y: 166, width: 356, height: 76) + static let pagerFrame = CGRect(x: 21, y: 258, width: 360, height: 466) + static let pageIndicatorFrame = CGRect(x: 126, y: 735, width: 150, height: 32) + static let folderFrame = CGRect(x: 10, y: 735, width: 105, height: 105) + static let uploadFrame = CGRect(x: 296, y: 752, width: 70, height: 73) + + static let columnSpacing: CGFloat = 18 + static let rowSpacing: CGFloat = 18 + static let cardCornerRadius: CGFloat = 18 + static let cardContentInset: CGFloat = 18 + static let searchCornerRadius: CGFloat = 20 + static let minimumControlSize: CGFloat = 44 + static let cardRotationDegrees: [Double] = [-3.2, 2.5, 1.7, -1.2, 2.1, -2.4] + + static func scale(for size: CGSize) -> CGFloat { + min(1, min(size.width / referenceSize.width, size.height / referenceSize.height)) + } +} diff --git a/Omo/Omo/RecallHomeView.swift b/Omo/Omo/RecallHomeView.swift new file mode 100644 index 0000000..d1729ae --- /dev/null +++ b/Omo/Omo/RecallHomeView.swift @@ -0,0 +1,420 @@ +import PhotosUI +import SwiftUI + +struct RecallHomeView: View { + @EnvironmentObject private var store: OmoStore + let onOpenLibrary: () -> Void + let onOpenProfile: () -> Void + let onOpenSettings: () -> Void + + @State private var selectedScreenshot: PhotosPickerItem? + @State private var showsAIConsent = false + @State private var selectionError = "" + @State private var deck: [MemoryCard] = [] + @State private var isRoundActive = false + @StateObject private var uploadCoordinator = ScreenshotUploadCoordinator() + @AppStorage(AIProcessingConsent.defaultsKey) private var allowsAIProcessing = false + + var body: some View { + RecallHomeScaffold( + mascotIsInteractive: !store.dueCards.isEmpty && !isRoundActive, + mascotHint: "点击开始抽取这一轮知识卡", + onMascotTap: beginRound, + onOpenProfile: onOpenProfile, + onOpenSettings: onOpenSettings + ) { + ZStack(alignment: .topLeading) { + if isRoundActive { + ZStack(alignment: .topLeading) { + RecallRoundView( + cards: deck, + onAssess: assess, + onComplete: finishRound + ) + + persistentHomeActions + } + .frame( + width: RecallHomeMetrics.referenceSize.width, + height: RecallHomeMetrics.referenceSize.height, + alignment: .topLeading + ) + } else if store.cards.isEmpty { + firstLaunchContent + } else { + idleContent + } + + if let card = store.notificationRecallCard { + ZStack(alignment: .topLeading) { + RecallRoundView( + cards: [card], + onAssess: assessNotification, + onComplete: finishNotificationRecall + ) + persistentHomeActions + } + .id("notification-\(card.id)") + .frame( + width: RecallHomeMetrics.referenceSize.width, + height: RecallHomeMetrics.referenceSize.height, + alignment: .topLeading + ) + .transition(.opacity.combined(with: .scale(scale: 1.02))) + .zIndex(20) + } + } + } + .onChange(of: selectedScreenshot) { _, item in + loadScreenshot(from: item) + } + .alert("允许 AI 处理这张截图?", isPresented: $showsAIConsent) { + Button("取消", role: .cancel) { + uploadCoordinator.cancelConsent() + selectedScreenshot = nil + } + Button("同意并生成") { + allowsAIProcessing = true + Task { + _ = await uploadCoordinator.confirmConsent { data in + await store.createCard(from: data) + } + } + } + } message: { + Text("截图会经 Omo 的测试服务发送给第三方 AI,用于识别内容并生成记忆卡。请不要上传含敏感个人信息的截图。") + } + } + + private var firstLaunchContent: some View { + ZStack(alignment: .topLeading) { + Text("上传第一张知识截屏") + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundStyle(RecallPalette.coral) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.82) + .frame(width: RecallHomeMetrics.promptFrame.width, height: RecallHomeMetrics.promptFrame.height) + .position(x: RecallHomeMetrics.promptFrame.midX, y: RecallHomeMetrics.promptFrame.midY) + + statusText + + libraryButton + + Image("FirstLaunchArrow") + .resizable() + .scaledToFit() + .frame(width: RecallHomeMetrics.uploadArrowFrame.width, height: RecallHomeMetrics.uploadArrowFrame.height) + .position(x: RecallHomeMetrics.uploadArrowFrame.midX, y: RecallHomeMetrics.uploadArrowFrame.midY) + .accessibilityHidden(true) + + uploadPicker(label: "上传第一张知识截屏") + } + } + + private var idleContent: some View { + ZStack(alignment: .topLeading) { + if !store.dueCards.isEmpty { + Image("FirstLaunchArrow") + .resizable() + .renderingMode(.template) + .scaledToFit() + .scaleEffect(x: 1, y: -1) + .foregroundStyle(RecallPalette.teal.opacity(0.78)) + .frame(width: RecallHomeMetrics.mascotArrowFrame.width, height: RecallHomeMetrics.mascotArrowFrame.height) + .position(x: RecallHomeMetrics.mascotArrowFrame.midX, y: RecallHomeMetrics.mascotArrowFrame.midY) + .accessibilityHidden(true) + } + + statusText + persistentHomeActions + } + } + + @ViewBuilder + private var persistentHomeActions: some View { + libraryButton + uploadPicker(label: "上传新的知识截屏") + } + + private var libraryButton: some View { + Button(action: onOpenLibrary) { + Image("FirstLaunchFolder") + .resizable() + .scaledToFit() + .frame( + width: RecallHomeMetrics.folderFrame.width, + height: RecallHomeMetrics.folderFrame.height + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .position( + x: RecallHomeMetrics.folderFrame.midX, + y: RecallHomeMetrics.folderFrame.midY + ) + .accessibilityLabel("打开知识库") + } + + @ViewBuilder + private var statusText: some View { + if case .failed(let loadMessage) = store.loadState, + store.failedScreenshotJobs.isEmpty, + store.activeScreenshotJobs.isEmpty { + Button { + Task { await store.load() } + } label: { + Text("连接失败,点此重试") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(RecallPalette.error) + .frame(width: RecallHomeMetrics.statusFrame.width, height: RecallHomeMetrics.statusFrame.height) + .position(x: RecallHomeMetrics.statusFrame.midX, y: RecallHomeMetrics.statusFrame.midY) + .accessibilityHint(loadMessage) + } else if let failed = store.failedScreenshotJobs.first { + Button { + Task { await store.retryScreenshotJob(failed) } + } label: { + Text("整理失败,点此重试") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(RecallPalette.error) + .frame(width: RecallHomeMetrics.statusFrame.width, height: RecallHomeMetrics.statusFrame.height) + .position(x: RecallHomeMetrics.statusFrame.midX, y: RecallHomeMetrics.statusFrame.midY) + .accessibilityHint(failed.errorMessage) + } else { + let fallback = selectionError.isEmpty ? store.message : selectionError + let activeCount = store.activeScreenshotJobs.count + let text = activeCount > 0 + ? (activeCount == 1 ? "正在整理知识卡" : "正在整理 \(activeCount) 张知识卡") + : fallback + if !text.isEmpty { + Text(text) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(activeCount > 0 ? RecallPalette.teal : RecallPalette.error) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(width: RecallHomeMetrics.statusFrame.width, height: RecallHomeMetrics.statusFrame.height) + .position(x: RecallHomeMetrics.statusFrame.midX, y: RecallHomeMetrics.statusFrame.midY) + } + } + } + + private func uploadPicker(label: String) -> some View { + return PhotosPicker(selection: $selectedScreenshot, matching: .images, photoLibrary: .shared()) { + Image("FirstLaunchUpload") + .resizable() + .frame(width: RecallHomeMetrics.uploadFrame.width, height: RecallHomeMetrics.uploadFrame.height) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .position(x: RecallHomeMetrics.uploadFrame.midX, y: RecallHomeMetrics.uploadFrame.midY) + .accessibilityLabel(label) + .accessibilityHint("打开系统照片选择器,只选择一张截图") + } + + private func loadScreenshot(from item: PhotosPickerItem?) { + guard let item else { return } + selectionError = "" + Task { + defer { selectedScreenshot = nil } + guard let data = try? await item.loadTransferable(type: Data.self), !data.isEmpty else { + selectionError = "读取图片失败,请重新选择。" + return + } + if AIProcessingConsent.requiresPrompt(hasConsent: allowsAIProcessing) { + uploadCoordinator.receive(data, hasConsent: false) + showsAIConsent = true + } else { + uploadCoordinator.receive(data, hasConsent: true) + _ = await uploadCoordinator.submitReceived { image in + await store.createCard(from: image) + } + } + } + } + + private func beginRound() { + let candidates = store.nextRecallDeck + guard !candidates.isEmpty else { return } + deck = candidates + UISelectionFeedbackGenerator().selectionChanged() + withAnimation(.spring(response: 0.48, dampingFraction: 0.84)) { + isRoundActive = true + } + } + + private func assess(_ card: MemoryCard, as assessment: MemoryAssessment) async throws { + _ = try await store.assess(card, as: assessment) + } + + private func assessNotification( + _ card: MemoryCard, + as assessment: MemoryAssessment + ) async throws { + _ = try await store.assess(card, as: assessment) + } + + private func finishNotificationRecall() { + withAnimation(.easeOut(duration: 0.22)) { + store.notificationRecallCard = nil + } + } + + private func finishRound() { + withAnimation(.easeOut(duration: 0.22)) { + isRoundActive = false + deck = [] + } + } +} + +private struct RecallHomeScaffold: View { + let mascotIsInteractive: Bool + let mascotHint: String + let onMascotTap: () -> Void + let onOpenProfile: () -> Void + let onOpenSettings: () -> Void + @ViewBuilder let content: Content + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var drawerIsOpen = false + @State private var mascotBreathes = false + + init( + mascotIsInteractive: Bool, + mascotHint: String, + onMascotTap: @escaping () -> Void, + onOpenProfile: @escaping () -> Void, + onOpenSettings: @escaping () -> Void, + @ViewBuilder content: () -> Content + ) { + self.mascotIsInteractive = mascotIsInteractive + self.mascotHint = mascotHint + self.onMascotTap = onMascotTap + self.onOpenProfile = onOpenProfile + self.onOpenSettings = onOpenSettings + self.content = content() + } + + var body: some View { + GeometryReader { geometry in + let scale = RecallHomeMetrics.scale(for: geometry.size) + let canvasSize = CGSize( + width: RecallHomeMetrics.referenceSize.width * scale, + height: RecallHomeMetrics.referenceSize.height * scale + ) + ZStack(alignment: .leading) { + RecallPalette.background.ignoresSafeArea() + referenceCanvas + .frame(width: RecallHomeMetrics.referenceSize.width, height: RecallHomeMetrics.referenceSize.height) + .scaleEffect(scale) + .frame(width: canvasSize.width, height: canvasSize.height) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(!drawerIsOpen) + .accessibilityHidden(drawerIsOpen) + + if drawerIsOpen { + RecallPalette.scrim + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { drawerIsOpen = false } + drawer(width: min(RecallHomeMetrics.drawerMaxWidth, geometry.size.width * RecallHomeMetrics.drawerWidthRatio)) + .transition(.move(edge: .leading)) + } + } + .animation(reduceMotion ? nil : .spring(response: 0.32, dampingFraction: 0.88), value: drawerIsOpen) + } + } + + private var referenceCanvas: some View { + ZStack(alignment: .topLeading) { + Image("FirstLaunchPanel") + .resizable() + .frame(width: RecallHomeMetrics.panelFrame.width, height: RecallHomeMetrics.panelFrame.height) + .position(x: RecallHomeMetrics.panelFrame.midX, y: RecallHomeMetrics.panelFrame.midY) + .accessibilityHidden(true) + + Button { drawerIsOpen = true } label: { + Image("FirstLaunchMenu") + .resizable() + .frame(width: RecallHomeMetrics.menuFrame.width, height: RecallHomeMetrics.menuFrame.height) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .position(x: RecallHomeMetrics.menuFrame.midX, y: RecallHomeMetrics.menuFrame.midY) + .accessibilityLabel("打开菜单") + + mascot + content + } + } + + @ViewBuilder + private var mascot: some View { + let image = Image("OmoPoseStretch") + .resizable() + .scaledToFit() + .frame(width: RecallHomeMetrics.mascotFrame.width, height: RecallHomeMetrics.mascotFrame.height) + .scaleEffect(mascotIsInteractive && mascotBreathes && !reduceMotion ? 1.035 : 1) + + if mascotIsInteractive { + Button(action: onMascotTap) { image.contentShape(Rectangle()) } + .buttonStyle(.plain) + .position(x: RecallHomeMetrics.mascotFrame.midX, y: RecallHomeMetrics.mascotFrame.midY) + .accessibilityLabel("哦莫 记忆伙伴") + .accessibilityHint(mascotHint) + .task { startBreathing() } + } else { + image + .position(x: RecallHomeMetrics.mascotFrame.midX, y: RecallHomeMetrics.mascotFrame.midY) + .accessibilityHidden(true) + } + } + + private func drawer(width: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Omo") + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundStyle(RecallPalette.ink) + .padding(.bottom, 16) + drawerButton("Profile", symbol: "person.crop.circle", action: onOpenProfile) + drawerButton("Settings", symbol: "gearshape", action: onOpenSettings) + Spacer() + } + .padding(.horizontal, 24) + .padding(.top, 72) + .frame(width: width) + .frame(maxHeight: .infinity, alignment: .topLeading) + .background(RecallPalette.drawer) + .clipShape(UnevenRoundedRectangle(bottomTrailingRadius: 28, topTrailingRadius: 28, style: .continuous)) + .ignoresSafeArea() + .accessibilityAddTraits(.isModal) + } + + private func drawerButton(_ title: String, symbol: String, action: @escaping () -> Void) -> some View { + Button { + drawerIsOpen = false + action() + } label: { + Label(title, systemImage: symbol) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RecallPalette.ink) + .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func startBreathing() { + guard !reduceMotion, !mascotBreathes else { return } + withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) { + mascotBreathes = true + } + } +} diff --git a/Omo/Omo/RecallInteractionState.swift b/Omo/Omo/RecallInteractionState.swift new file mode 100644 index 0000000..3ce1213 --- /dev/null +++ b/Omo/Omo/RecallInteractionState.swift @@ -0,0 +1,82 @@ +import Foundation + +enum RecallCardPhase: Equatable { + case covered + case scratching + case revealed + case submitting(MemoryAssessment) + case submissionFailed(MemoryAssessment) +} + +struct RecallRoundState: Equatable { + static let revealThreshold = 0.8 + + let cardCount: Int + private(set) var currentIndex = 0 + private(set) var coverage: Double = 0 + private(set) var phase: RecallCardPhase = .covered + + var isComplete: Bool { currentIndex >= cardCount } + var showsRating: Bool { phase == .revealed || isSubmissionFailure } + var showsContext: Bool { !canScratch } + var canScratch: Bool { phase == .covered || phase == .scratching } + + private var isSubmissionFailure: Bool { + if case .submissionFailed = phase { return true } + return false + } + + mutating func updateCoverage(_ value: Double) { + guard canScratch else { return } + coverage = min(1, max(coverage, value)) + if coverage >= Self.revealThreshold { + coverage = 1 + phase = .revealed + } else if coverage > 0 { + phase = .scratching + } + } + + mutating func beginSubmission(_ assessment: MemoryAssessment) { + guard showsRating else { return } + phase = .submitting(assessment) + } + + mutating func failSubmission() { + guard case .submitting(let assessment) = phase else { return } + phase = .submissionFailed(assessment) + } + + mutating func retryAssessment() -> MemoryAssessment? { + guard case .submissionFailed(let assessment) = phase else { return nil } + phase = .submitting(assessment) + return assessment + } + + mutating func finishSubmission() { + guard case .submitting = phase else { return } + currentIndex += 1 + coverage = 0 + phase = .covered + } +} + +enum RecallRatingScale { + static let cancelPosition = 0.0 + static let nodes: [(assessment: MemoryAssessment, position: Double)] = [ + (.forgot, 0.42), + (.fuzzy, 0.70), + (.remembered, 0.97) + ] + + static func nearestAssessment(at position: Double) -> MemoryAssessment? { + let value = min(1, max(0, position)) + guard value >= nodes[0].position / 2 else { return nil } + return nodes.min(by: { abs($0.position - value) < abs($1.position - value) })?.assessment + } + + static func position(for assessment: MemoryAssessment?) -> Double { + guard let assessment else { return cancelPosition } + return nodes.first(where: { $0.assessment == assessment })?.position ?? cancelPosition + } +} diff --git a/Omo/Omo/RecallKnowledgeCardView.swift b/Omo/Omo/RecallKnowledgeCardView.swift new file mode 100644 index 0000000..db36228 --- /dev/null +++ b/Omo/Omo/RecallKnowledgeCardView.swift @@ -0,0 +1,506 @@ +import SwiftUI +import UIKit + +struct RecallKnowledgeCardStack: View { + let cards: [MemoryCard] + let currentIndex: Int + @Binding var coverage: Double + let removalProgress: CGFloat + let isScratchEnabled: Bool + let allowsContext: Bool + + private var visibleCards: ArraySlice { + let end = min(cards.count, currentIndex + RecallCardMetrics.visibleLayerCount) + return cards[currentIndex.. some View { + let spread = CGFloat(depth) + return RoundedRectangle( + cornerRadius: RecallCardMetrics.cornerRadius, + style: .continuous + ) + .fill(depth == 1 ? RecallPalette.tealSoft : RecallPalette.card) + .overlay( + RoundedRectangle(cornerRadius: RecallCardMetrics.cornerRadius) + .stroke(RecallPalette.teal, lineWidth: 1) + ) + .shadow( + color: rarityColor(card.rarity).opacity(depth == 1 ? 0.55 : 0.12), + radius: depth == 1 ? 13 : 4 + ) + .shadow(color: Color.black.opacity(0.16), radius: 3, x: 2, y: 4) + .rotationEffect(.degrees(Double(depth) * 1.7)) + .offset(x: spread * 5, y: -spread * 7) + .accessibilityHidden(true) + } + + private func rarityColor(_ rarity: String) -> Color { + switch rarity { + case "SSR": RecallPalette.coral + case "SR": Color(red: 0.49, green: 0.72, blue: 0.80) + default: RecallPalette.teal + } + } +} + +private struct RecallKnowledgeCardView: View { + let card: MemoryCard + @Binding var coverage: Double + let isScratchEnabled: Bool + let allowsContext: Bool + + @State private var showsContext = false + + private var segments: RecallKnowledgeSegments { + precondition(card.knowledgeSegments != nil, "Recall deck contains an invalid card") + return card.knowledgeSegments! + } + + var body: some View { + ZStack(alignment: .topTrailing) { + RoundedRectangle( + cornerRadius: RecallCardMetrics.cornerRadius, + style: .continuous + ) + .fill(RecallPalette.card) + .overlay( + RoundedRectangle(cornerRadius: RecallCardMetrics.cornerRadius) + .stroke(RecallPalette.teal, lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.22), radius: 4, x: 3, y: 5) + + knowledgeContent + + if allowsContext { + Button { showsContext = true } label: { + Image(systemName: "line.3.horizontal") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(RecallPalette.teal) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("查看完整知识上下文") + .accessibilityHint("展开解释与来源,不改变当前刮开进度") + } + } + .sheet(isPresented: $showsContext) { + RecallContextView(card: card) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .accessibilityElement(children: .contain) + } + + private var knowledgeContent: some View { + ViewThatFits(in: .vertical) { + knowledgeFlow(fontSize: 16) + knowledgeFlow(fontSize: 15) + knowledgeFlow(fontSize: 14) + } + .frame( + width: RecallHomeMetrics.cardStackFrame.width + - RecallCardMetrics.contentInset * 2, + height: RecallHomeMetrics.cardStackFrame.height + - RecallCardMetrics.contentInset * 2, + alignment: .center + ) + .padding(RecallCardMetrics.contentInset) + } + + private func knowledgeFlow(fontSize: CGFloat) -> some View { + RecallInlineKnowledgeLayout(horizontalSpacing: 0, verticalSpacing: 5) { + ForEach(Array(inlineUnits(segments.prefix).enumerated()), id: \.offset) { _, unit in + Text(unit) + .font(.system(size: fontSize)) + .foregroundStyle(RecallPalette.ink) + } + + ScratchSemanticToken( + text: segments.semantic, + fontSize: fontSize, + coverage: $coverage, + isEnabled: isScratchEnabled + ) + + ForEach(Array(inlineUnits(segments.suffix).enumerated()), id: \.offset) { _, unit in + Text(unit) + .font(.system(size: fontSize)) + .foregroundStyle(RecallPalette.ink) + } + } + .fixedSize(horizontal: false, vertical: true) + } + + private func inlineUnits(_ text: String) -> [String] { + let punctuation = "::,,。.;;!?!?、" + return text.reduce(into: [String]()) { units, character in + if punctuation.contains(character), !units.isEmpty { + units[units.count - 1].append(character) + } else { + units.append(String(character)) + } + } + } +} + +private struct RecallInlineKnowledgeLayout: Layout { + let horizontalSpacing: CGFloat + let verticalSpacing: CGFloat + + private struct Line { + var entries: [(index: Int, size: CGSize)] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let lines = makeLines(width: proposal.width ?? 218, subviews: subviews) + return CGSize( + width: proposal.width ?? lines.map(\.width).max() ?? 0, + height: lines.map(\.height).reduce(0, +) + + verticalSpacing * CGFloat(max(0, lines.count - 1)) + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let lines = makeLines(width: bounds.width, subviews: subviews) + var y = bounds.minY + for line in lines { + var x = bounds.minX + for entry in line.entries { + subviews[entry.index].place( + at: CGPoint(x: x, y: y + (line.height - entry.size.height) / 2), + anchor: .topLeading, + proposal: ProposedViewSize(entry.size) + ) + x += entry.size.width + horizontalSpacing + } + y += line.height + verticalSpacing + } + } + + private func makeLines(width: CGFloat, subviews: Subviews) -> [Line] { + var lines: [Line] = [] + var line = Line() + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let proposedWidth = line.entries.isEmpty + ? size.width + : line.width + horizontalSpacing + size.width + if !line.entries.isEmpty, proposedWidth > width { + lines.append(line) + line = Line() + } + line.entries.append((index, size)) + line.width = line.entries.count == 1 + ? size.width + : line.width + horizontalSpacing + size.width + line.height = max(line.height, size.height) + } + if !line.entries.isEmpty { lines.append(line) } + return lines + } +} + +private struct ScratchSemanticToken: View { + let text: String + let fontSize: CGFloat + @Binding var coverage: Double + let isEnabled: Bool + + @State private var paths: [[CGPoint]] = [] + @State private var coveredCells: Set = [] + @State private var isDrawing = false + @State private var didAnnounceReveal = false + + private let columns = 12 + private let rows = 3 + + var body: some View { + GeometryReader { geometry in + ZStack { + Canvas { context, size in + let rendered = context.resolve( + Text(text) + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(RecallPalette.coral) + ) + context.draw( + rendered, + at: CGPoint(x: size.width / 2, y: size.height / 2), + anchor: .center + ) + } + .accessibilityHidden(true) + + if coverage < 1 { + Canvas { context, size in + context.fill( + Path( + roundedRect: CGRect(origin: .zero, size: size), + cornerRadius: size.height / 2 + ), + with: .color(RecallPalette.tealSoft) + ) + context.blendMode = .destinationOut + for normalizedPath in paths where normalizedPath.count > 1 { + var path = Path() + path.move(to: rendered(normalizedPath[0], in: size)) + for point in normalizedPath.dropFirst() { + path.addLine(to: rendered(point, in: size)) + } + context.stroke( + path, + with: .color(.black), + style: StrokeStyle( + lineWidth: RecallCardMetrics.brushDiameter, + lineCap: .round, + lineJoin: .round + ) + ) + } + } + .drawingGroup() + .allowsHitTesting(false) + } + } + .contentShape(Capsule()) + .gesture(scratchGesture(in: geometry.size)) + } + .frame(width: tokenWidth, height: RecallCardMetrics.semanticHeight) + .allowsHitTesting(isEnabled && coverage < 1) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + coverage >= 1 ? "承重语义,\(text)" : "被遮住的承重语义" + ) + .accessibilityValue( + coverage >= 1 ? "已揭示" : "已刮开 \(Int(coverage * 100))%" + ) + .accessibilityHint( + coverage >= 1 + ? "关键词已经完整揭示" + : "滑动逐步刮开关键词,达到百分之八十后完整显示" + ) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: adjustCoverage(by: 0.2) + case .decrement: adjustCoverage(by: -0.2) + @unknown default: break + } + } + .onAppear(perform: restoreVisualCoverage) + } + + private var tokenWidth: CGFloat { + let font = UIFont.systemFont(ofSize: fontSize, weight: .semibold) + let measured = (text as NSString).size(withAttributes: [.font: font]).width + 16 + return min(150, max(70, measured)) + } + + private func scratchGesture(in size: CGSize) -> some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + guard isEnabled, coverage < 1 else { return } + if !isDrawing { + isDrawing = true + paths.append([]) + } + paths[paths.count - 1].append(normalized(value.location, in: size)) + markCells(around: value.location, in: size) + } + .onEnded { _ in isDrawing = false } + } + + private func normalized(_ point: CGPoint, in size: CGSize) -> CGPoint { + guard size.width > 0, size.height > 0 else { return .zero } + return CGPoint( + x: min(1, max(0, point.x / size.width)), + y: min(1, max(0, point.y / size.height)) + ) + } + + private func rendered(_ point: CGPoint, in size: CGSize) -> CGPoint { + CGPoint(x: point.x * size.width, y: point.y * size.height) + } + + private func markCells(around point: CGPoint, in size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + let column = min(columns - 1, max(0, Int(point.x / size.width * CGFloat(columns)))) + let row = min(rows - 1, max(0, Int(point.y / size.height * CGFloat(rows)))) + let cellWidth = size.width / CGFloat(columns) + let cellHeight = size.height / CGFloat(rows) + let radiusColumns = max( + 0, + Int(((RecallCardMetrics.brushDiameter / 2) / max(1, cellWidth)).rounded()) + ) + let radiusRows = max( + 0, + Int(((RecallCardMetrics.brushDiameter / 2) / max(1, cellHeight)).rounded()) + ) + + for x in max(0, column - radiusColumns)...min(columns - 1, column + radiusColumns) { + for y in max(0, row - radiusRows)...min(rows - 1, row + radiusRows) { + coveredCells.insert(y * columns + x) + } + } + updateCoverage() + } + + private func adjustCoverage(by delta: Double) { + guard isEnabled, coverage < 1 else { return } + let totalCells = columns * rows + let target = min(1, max(0, coverage + delta)) + let targetCount = Int((target * Double(totalCells)).rounded()) + if targetCount > coveredCells.count { + for cell in 0..= targetCount { break } + } + } else if targetCount < coveredCells.count { + for cell in coveredCells.sorted().reversed() { + coveredCells.remove(cell) + if coveredCells.count <= targetCount { break } + } + } + updateCoverage() + } + + private func updateCoverage() { + let rawCoverage = Double(coveredCells.count) / Double(columns * rows) + coverage = rawCoverage >= RecallRoundState.revealThreshold ? 1 : rawCoverage + if coverage == 1, !didAnnounceReveal { + didAnnounceReveal = true + UIImpactFeedbackGenerator(style: .light).impactOccurred() + UIAccessibility.post(notification: .announcement, argument: "关键词已完整揭示") + } + } + + private func restoreVisualCoverage() { + guard coverage > 0, coverage < 1, paths.isEmpty else { return } + let endX = min(0.95, max(0.12, coverage)) + paths = [[CGPoint(x: 0.06, y: 0.5), CGPoint(x: endX, y: 0.5)]] + coveredCells = Set((0..<(columns * rows)).prefix(Int(coverage * Double(columns * rows)))) + } +} + +private struct RecallContextView: View { + let card: MemoryCard + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 22) { + Text(weightedKnowledge) + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(RecallPalette.ink) + .lineSpacing(7) + .fixedSize(horizontal: false, vertical: true) + + Text(card.explanation) + .font(.body) + .foregroundStyle(RecallPalette.ink.opacity(0.72)) + .lineSpacing(6) + .fixedSize(horizontal: false, vertical: true) + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Label(sourceStatusTitle, systemImage: sourceStatusSymbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(RecallPalette.teal) + + Text(card.sourceTitle) + .font(.body.weight(.semibold)) + .foregroundStyle(RecallPalette.ink) + + if card.sourceIsVerified, + let value = card.sourceUrl, + let url = URL(string: value) { + Link(destination: url) { + Label("查看原文", systemImage: "arrow.up.right.square") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(RecallPalette.teal) + .frame(minHeight: 44) + } + } + } + } + .padding(24) + } + .background(RecallPalette.drawer) + .navigationTitle("完整上下文") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("完成") { dismiss() } + .foregroundStyle(RecallPalette.teal) + } + } + } + } + + private var weightedKnowledge: AttributedString { + var value = AttributedString(card.coreKnowledge) + value.font = .system(size: 20, weight: .regular) + value.foregroundColor = RecallPalette.ink + if let hidden = card.hiddenSemantic, + let range = value.range(of: hidden) { + value[range].font = .system(size: 20, weight: .semibold) + value[range].foregroundColor = RecallPalette.coral + } + return value + } + + private var sourceStatusTitle: String { + switch card.sourceStatus { + case "verified": "已找到并核对原始来源" + case "partial": "来源上下文不完整" + default: "仅根据截图建立上下文" + } + } + + private var sourceStatusSymbol: String { + switch card.sourceStatus { + case "verified": "checkmark.seal.fill" + case "partial": "link.badge.plus" + default: "photo" + } + } +} diff --git a/Omo/Omo/RecallRatingSlider.swift b/Omo/Omo/RecallRatingSlider.swift new file mode 100644 index 0000000..a94c4d2 --- /dev/null +++ b/Omo/Omo/RecallRatingSlider.swift @@ -0,0 +1,229 @@ +import SwiftUI +import UIKit + +struct RecallRatingSlider: View { + let isEnabled: Bool + let onCommit: (MemoryAssessment) -> Void + let onCancel: () -> Void + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var position = RecallRatingScale.cancelPosition + @State private var activeAssessment: MemoryAssessment? + + var body: some View { + GeometryReader { geometry in + let width = min(geometry.size.width, RecallRatingMetrics.trackWidth) + let travel = width - RecallRatingMetrics.knobSize.width + + ZStack(alignment: .topLeading) { + track(width: width, travel: travel) + labels(width: width, travel: travel) + knob(width: width, travel: travel) + } + .frame(width: width, height: RecallRatingMetrics.totalHeight) + .frame(maxWidth: .infinity, alignment: .center) + .contentShape(Rectangle()) + .gesture(dragGesture(travel: travel)) + .allowsHitTesting(isEnabled) + .opacity(isEnabled ? 1 : 0.72) + } + .frame(width: RecallRatingMetrics.trackWidth, height: RecallRatingMetrics.totalHeight) + .accessibilityRepresentation { + Slider( + value: .constant(Double(accessibilitySelectionIndex)), + in: 0...3, + step: 1 + ) + .accessibilityIdentifier("memory-rating-slider") + .accessibilityLabel("记忆状态") + .accessibilityValue(accessibilityValue) + .accessibilityHint("调整到忘记了、没记清或记住了,然后确认") + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + updateAccessibilitySelection(Double(accessibilitySelectionIndex + 1)) + case .decrement: + updateAccessibilitySelection(Double(accessibilitySelectionIndex - 1)) + @unknown default: + break + } + } + .accessibilityAction(named: "确认当前记忆状态", confirmAccessibilityValue) + } + } + + private func track(width: CGFloat, travel: CGFloat) -> some View { + ZStack(alignment: .leading) { + Capsule().fill(RecallPalette.card) + + ratingGradient + .frame(width: width, height: RecallRatingMetrics.trackHeight) + .clipShape(Capsule()) + .mask(alignment: .leading) { + Rectangle() + .frame(width: knobCenter(travel: travel)) + } + + ForEach(RecallRatingScale.nodes, id: \.assessment) { node in + Circle() + .fill(RecallPalette.teal) + .frame( + width: RecallRatingMetrics.nodeDiameter, + height: RecallRatingMetrics.nodeDiameter + ) + .position( + x: nodeCenter(for: node.assessment, travel: travel), + y: RecallRatingMetrics.trackHeight / 2 + ) + } + } + .frame(width: width, height: RecallRatingMetrics.trackHeight) + .overlay(Capsule().stroke(RecallPalette.teal, lineWidth: 1)) + .shadow(color: RecallPalette.ink.opacity(0.2), radius: 4, y: 4) + .offset(y: 1.75) + } + + private func labels(width: CGFloat, travel: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + ForEach(RecallRatingScale.nodes, id: \.assessment) { node in + Text(node.assessment.sliderTitle) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(RecallPalette.teal) + .frame(width: 48) + .position( + x: nodeCenter(for: node.assessment, travel: travel), + y: RecallRatingMetrics.labelTop + 8 + ) + } + } + .frame(width: width, height: RecallRatingMetrics.totalHeight) + .allowsHitTesting(false) + } + + private func knob(width: CGFloat, travel: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + Capsule() + .fill(RecallPalette.drawer) + .shadow(color: RecallPalette.ink.opacity(0.27), radius: 5, y: 3) + .frame( + width: RecallRatingMetrics.knobSize.width, + height: RecallRatingMetrics.knobSize.height + ) + .offset(x: CGFloat(position) * travel) + + ratingGradient + .frame(width: width, height: RecallRatingMetrics.knobSize.height) + .mask(alignment: .topLeading) { + Capsule().stroke(lineWidth: 1) + .frame( + width: RecallRatingMetrics.knobSize.width, + height: RecallRatingMetrics.knobSize.height + ) + .offset(x: CGFloat(position) * travel) + } + + Image(systemName: "arrow.right") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(RecallPalette.teal) + .frame( + width: RecallRatingMetrics.knobSize.width, + height: RecallRatingMetrics.knobSize.height + ) + .offset(x: CGFloat(position) * travel) + } + .frame(width: width, height: RecallRatingMetrics.knobSize.height) + } + + private func dragGesture(travel: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + guard isEnabled, travel > 0 else { return } + let rawPosition = Double( + min( + max(value.location.x - RecallRatingMetrics.knobSize.width / 2, 0), + travel + ) / travel + ) + position = rawPosition + let nextAssessment = RecallRatingScale.nearestAssessment(at: rawPosition) + if nextAssessment != activeAssessment { + activeAssessment = nextAssessment + if nextAssessment != nil { + UISelectionFeedbackGenerator().selectionChanged() + } + } + } + .onEnded { _ in + guard isEnabled else { return } + guard let assessment = activeAssessment else { + settle(on: nil) + onCancel() + return + } + settle(on: assessment) + onCommit(assessment) + } + } + + private func settle(on assessment: MemoryAssessment?) { + withAnimation(reduceMotion ? nil : .spring(response: 0.26, dampingFraction: 0.82)) { + activeAssessment = assessment + position = RecallRatingScale.position(for: assessment) + } + } + + private func knobCenter(travel: CGFloat) -> CGFloat { + RecallRatingMetrics.knobSize.width / 2 + CGFloat(position) * travel + } + + private func nodeCenter(for assessment: MemoryAssessment, travel: CGFloat) -> CGFloat { + RecallRatingMetrics.knobSize.width / 2 + + CGFloat(RecallRatingScale.position(for: assessment)) * travel + } + + private var ratingGradient: LinearGradient { + LinearGradient( + colors: [RecallPalette.card, RecallPalette.teal.opacity(0.8)], + startPoint: .leading, + endPoint: .trailing + ) + } + + private var accessibilityValue: String { + activeAssessment?.sliderTitle ?? "未选择" + } + + private var accessibilitySelectionIndex: Int { + let values: [MemoryAssessment?] = [nil, .forgot, .fuzzy, .remembered] + return values.firstIndex(where: { $0 == activeAssessment }) ?? 0 + } + + private func updateAccessibilitySelection(_ rawIndex: Double) { + let values: [MemoryAssessment?] = [nil, .forgot, .fuzzy, .remembered] + let index = min(values.count - 1, max(0, Int(rawIndex.rounded()))) + let next = values[index] + if next != activeAssessment { + UISelectionFeedbackGenerator().selectionChanged() + } + settle(on: next) + } + + private func confirmAccessibilityValue() { + guard isEnabled else { return } + guard let activeAssessment else { + onCancel() + return + } + onCommit(activeAssessment) + } +} + +private extension MemoryAssessment { + var sliderTitle: String { + switch self { + case .forgot: "忘记了" + case .fuzzy: "没记清" + case .remembered: "记住了" + } + } +} diff --git a/Omo/Omo/RecallRoundView.swift b/Omo/Omo/RecallRoundView.swift new file mode 100644 index 0000000..6e18449 --- /dev/null +++ b/Omo/Omo/RecallRoundView.swift @@ -0,0 +1,175 @@ +import SwiftUI +import UIKit + +struct RecallRoundView: View { + let cards: [MemoryCard] + let onAssess: (MemoryCard, MemoryAssessment) async throws -> Void + let onComplete: () -> Void + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var state: RecallRoundState + @State private var summonProgress: CGFloat = 0 + @State private var removalProgress: CGFloat = 0 + @State private var errorMessage = "" + + init( + cards: [MemoryCard], + onAssess: @escaping (MemoryCard, MemoryAssessment) async throws -> Void, + onComplete: @escaping () -> Void + ) { + self.cards = cards + self.onAssess = onAssess + self.onComplete = onComplete + var initialState = RecallRoundState(cardCount: cards.count) + #if DEBUG || OMO_TESTING + if ProcessInfo.processInfo.arguments.contains("-OmoRecallRevealed") { + initialState.updateCoverage(1) + } + #endif + _state = State(initialValue: initialState) + } + + var body: some View { + ZStack(alignment: .topLeading) { + if let card = currentCard { + RecallKnowledgeCardStack( + cards: cards, + currentIndex: state.currentIndex, + coverage: coverageBinding, + removalProgress: removalProgress, + isScratchEnabled: state.canScratch, + allowsContext: state.showsContext + ) + .id(card.id) + .scaleEffect(reduceMotion ? 1 : 0.9 + summonProgress * 0.1) + .rotationEffect(.degrees(reduceMotion ? 0 : Double(1 - summonProgress) * -7)) + .offset(y: reduceMotion ? 0 : (1 - summonProgress) * 190) + .opacity(reduceMotion ? 1 : 0.45 + summonProgress * 0.55) + .position( + x: RecallHomeMetrics.cardStackFrame.midX, + y: RecallHomeMetrics.cardStackFrame.midY + ) + + if state.showsRating { + RecallRatingSlider( + isEnabled: !isSubmitting, + onCommit: submit, + onCancel: { UISelectionFeedbackGenerator().selectionChanged() } + ) + .id("rating-\(card.id)") + .position( + x: RecallHomeMetrics.ratingFrame.midX, + y: RecallHomeMetrics.ratingFrame.midY + ) + .transition(.opacity.combined(with: .move(edge: .bottom))) + .opacity(1 - removalProgress) + } + + if isSubmitting { + ProgressView() + .tint(RecallPalette.teal) + .frame(width: 44, height: 44) + .position( + x: RecallHomeMetrics.ratingFrame.midX, + y: RecallHomeMetrics.ratingFrame.midY + ) + .accessibilityLabel("正在保存记忆状态") + } + + if !errorMessage.isEmpty { + Button(action: retry) { + Label("保存失败,点此重试", systemImage: "arrow.clockwise") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(RecallPalette.error) + .frame( + width: RecallHomeMetrics.errorFrame.width, + height: RecallHomeMetrics.errorFrame.height + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .position( + x: RecallHomeMetrics.errorFrame.midX, + y: RecallHomeMetrics.errorFrame.midY + ) + } + } + } + .animation(reduceMotion ? nil : .easeOut(duration: 0.22), value: state.showsRating) + .task(id: state.currentIndex) { await summonCurrentCard() } + } + + private var currentCard: MemoryCard? { + guard cards.indices.contains(state.currentIndex) else { return nil } + return cards[state.currentIndex] + } + + private var isSubmitting: Bool { + if case .submitting = state.phase { return true } + return false + } + + private var coverageBinding: Binding { + Binding(get: { state.coverage }, set: { state.updateCoverage($0) }) + } + + @MainActor + private func summonCurrentCard() async { + guard currentCard != nil else { + onComplete() + return + } + summonProgress = reduceMotion ? 1 : 0 + withAnimation(reduceMotion ? .none : .spring(response: 0.62, dampingFraction: 0.82)) { + summonProgress = 1 + } + if !reduceMotion { + try? await Task.sleep(for: .milliseconds(620)) + } + UISelectionFeedbackGenerator().selectionChanged() + } + + private func submit(_ assessment: MemoryAssessment) { + guard let card = currentCard, !isSubmitting else { return } + state.beginSubmission(assessment) + errorMessage = "" + Task { + do { + try await onAssess(card, assessment) + await advance() + } catch { + state.failSubmission() + errorMessage = error.localizedDescription + } + } + } + + private func retry() { + guard let card = currentCard, + let assessment = state.retryAssessment() else { return } + errorMessage = "" + Task { + do { + try await onAssess(card, assessment) + await advance() + } catch { + state.failSubmission() + errorMessage = error.localizedDescription + } + } + } + + @MainActor + private func advance() async { + UINotificationFeedbackGenerator().notificationOccurred(.success) + if reduceMotion { + state.finishSubmission() + } else { + withAnimation(.easeIn(duration: 0.28)) { removalProgress = 1 } + try? await Task.sleep(for: .milliseconds(280)) + state.finishSubmission() + removalProgress = 0 + } + if state.isComplete { onComplete() } + } +} diff --git a/Omo/Omo/Services/APIClient.swift b/Omo/Omo/Services/APIClient.swift index ce7edef..78858a4 100644 --- a/Omo/Omo/Services/APIClient.swift +++ b/Omo/Omo/Services/APIClient.swift @@ -1,19 +1,130 @@ import Foundation -struct APIClient { - #if DEBUG - static let defaultBaseURL = URL(string: "http://127.0.0.1:5174")! - #else - static let defaultBaseURL = URL(string: "https://shibei-production.up.railway.app")! +protocol OmoAPIProviding: Sendable { + func cards() async throws -> [MemoryCard] + func createCard(from imageData: Data) async throws -> MemoryCard + func screenshotJobs() async throws -> [ScreenshotJob] + func createScreenshotJob(from imageData: Data) async throws -> ScreenshotJob + func screenshotJob(id: String) async throws -> ScreenshotJob + func retryScreenshotJob(id: String, imageData: Data) async throws -> ScreenshotJob + func assess(_ card: MemoryCard, as assessment: MemoryAssessment) async throws -> MemoryCard + func delete(_ card: MemoryCard) async throws +} + +extension OmoAPIProviding { + func screenshotJobs() async throws -> [ScreenshotJob] { [] } + + func createScreenshotJob(from imageData: Data) async throws -> ScreenshotJob { + throw APIError.server("截图任务接口尚未配置。") + } + + func screenshotJob(id: String) async throws -> ScreenshotJob { + throw APIError.server("截图任务接口尚未配置。") + } + + func retryScreenshotJob(id: String, imageData: Data) async throws -> ScreenshotJob { + throw APIError.server("截图任务接口尚未配置。") + } +} + +enum AppEnvironmentError: LocalizedError, Equatable { + case missingAPIBaseURL + case invalidAPIBaseURL + + var errorDescription: String? { + switch self { + case .missingAPIBaseURL: + "测试服务尚未配置。" + case .invalidAPIBaseURL: + "测试服务地址无效。" + } + } +} + +enum AppEnvironment { + static let apiBaseURLInfoKey = "OmoAPIBaseURL" + #if DEBUG || OMO_TESTING + static let debugLocalhostURL = URL(string: "http://127.0.0.1:5174")! #endif - private let baseURL: URL + static func currentAPIBaseURL() throws -> URL { + try resolveAPIBaseURL( + infoDictionary: Bundle.main.infoDictionary ?? [:], + processEnvironment: ProcessInfo.processInfo.environment, + allowsDebugLocalhostFallback: allowsDebugLocalhostFallback + ) + } + + static func resolveAPIBaseURL( + infoDictionary: [String: Any], + processEnvironment: [String: String], + allowsDebugLocalhostFallback: Bool + ) throws -> URL { + let environmentValue = allowsDebugLocalhostFallback + ? clean(processEnvironment["OMO_API_BASE_URL"]) + : nil + let bundleValue = clean(infoDictionary[apiBaseURLInfoKey] as? String) + guard let rawValue = environmentValue ?? bundleValue else { + #if DEBUG || OMO_TESTING + if allowsDebugLocalhostFallback { return debugLocalhostURL } + #endif + throw AppEnvironmentError.missingAPIBaseURL + } + guard let components = URLComponents(string: rawValue), + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), + !host.isEmpty, + !isBlockedLegacyProductionHost(host), + let url = components.url else { + throw AppEnvironmentError.invalidAPIBaseURL + } + + if scheme == "https" { return url } + #if DEBUG || OMO_TESTING + if allowsDebugLocalhostFallback, + scheme == "http", + ["127.0.0.1", "localhost"].contains(host) { + return url + } + #endif + throw AppEnvironmentError.invalidAPIBaseURL + } + + private static var allowsDebugLocalhostFallback: Bool { + #if DEBUG || OMO_TESTING + true + #else + false + #endif + } + + private static func clean(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + !value.contains("$(") else { return nil } + return value + } + + private static func isBlockedLegacyProductionHost(_ host: String) -> Bool { + // Keep the retired production endpoint out of distributable binaries while still + // rejecting it if it is accidentally supplied by a build configuration. + host.utf8.reduce(UInt64(5_381)) { hash, byte in + ((hash &* 33) ^ UInt64(byte)) + } == 0x561b_bc0f_374f_6d64 + } +} + +struct APIClient: Sendable { + private let baseURL: URL? private let session: URLSession private let deviceID: String - init(baseURL: URL = APIClient.defaultBaseURL, session: URLSession = .shared) { - self.baseURL = ProcessInfo.processInfo.environment["OMO_API_BASE_URL"] - .flatMap(URL.init(string:)) ?? baseURL + init(baseURL: URL? = nil, session: URLSession = .shared) { + self.baseURL = baseURL ?? (try? AppEnvironment.currentAPIBaseURL()) self.session = session self.deviceID = Self.deviceID() } @@ -37,6 +148,53 @@ struct APIClient { return response.card } + func screenshotJobs() async throws -> [ScreenshotJob] { + let response: ScreenshotJobsResponse = try await request("/api/screenshot-jobs") + return response.jobs + } + + func createScreenshotJob(from imageData: Data) async throws -> ScreenshotJob { + let body = ScreenshotRequest( + imageBase64: imageData.base64EncodedString(), + mimeType: "image/jpeg" + ) + let response: ScreenshotJobResponse = try await request( + "/api/screenshot-jobs", + method: "POST", + body: body + ) + return response.job + } + + func screenshotJob(id: String) async throws -> ScreenshotJob { + let response: ScreenshotJobResponse = try await request( + "/api/screenshot-jobs/\(id)" + ) + return response.job + } + + func retryScreenshotJob(id: String, imageData: Data) async throws -> ScreenshotJob { + let body = ScreenshotRequest( + imageBase64: imageData.base64EncodedString(), + mimeType: "image/jpeg" + ) + let response: ScreenshotJobResponse = try await request( + "/api/screenshot-jobs/\(id)/retry", + method: "POST", + body: body + ) + return response.job + } + + func searchKnowledgeLibrary(query: String) async throws -> KnowledgeLibrarySearchResponse { + let response: KnowledgeLibrarySearchAPIResponse = try await request( + "/api/memory-cards/search", + method: "POST", + body: KnowledgeLibrarySearchAPIRequest(query: query) + ) + return KnowledgeLibrarySearchResponse(orderedCardIDs: response.orderedCardIDs) + } + func assess(_ card: MemoryCard, as assessment: MemoryAssessment) async throws -> MemoryCard { let body = AssessmentRequest( assessment: assessment, @@ -74,7 +232,11 @@ struct APIClient { body: Body?, timeout: TimeInterval = 30 ) async throws -> Response { + guard let baseURL else { + throw AppEnvironment.currentAPIBaseURLConfigurationError + } var request = URLRequest(url: baseURL.appending(path: path)) + request.cachePolicy = .reloadIgnoringLocalCacheData request.httpMethod = method request.timeoutInterval = timeout request.setValue("application/json", forHTTPHeaderField: "Accept") @@ -109,10 +271,31 @@ struct APIClient { } } +extension APIClient: OmoAPIProviding {} + +private extension AppEnvironment { + static var currentAPIBaseURLConfigurationError: Error { + do { + _ = try currentAPIBaseURL() + return AppEnvironmentError.invalidAPIBaseURL + } catch { + return error + } + } +} + private struct APIErrorBody: Decodable { let message: String } +private struct KnowledgeLibrarySearchAPIRequest: Encodable { + let query: String +} + +private struct KnowledgeLibrarySearchAPIResponse: Decodable { + let orderedCardIDs: [String] +} + enum APIError: LocalizedError { case invalidResponse case server(String) diff --git a/Omo/Omo/Services/RecallNotificationRouter.swift b/Omo/Omo/Services/RecallNotificationRouter.swift new file mode 100644 index 0000000..2fde401 --- /dev/null +++ b/Omo/Omo/Services/RecallNotificationRouter.swift @@ -0,0 +1,32 @@ +import Foundation +import UserNotifications + +final class RecallNotificationRouter: NSObject, ObservableObject, UNUserNotificationCenterDelegate, @unchecked Sendable { + @Published private(set) var cardID: String? + + override init() { + super.init() + UNUserNotificationCenter.current().delegate = self + } + + func consume(_ consumedCardID: String) { + guard cardID == consumedCardID else { return } + cardID = nil + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + guard let cardID = response.notification.request.content.userInfo["cardID"] as? String, + !cardID.isEmpty else { return } + await MainActor.run { self.cardID = cardID } + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [] + } +} diff --git a/Omo/Omo/Services/RecallNotificationScheduler.swift b/Omo/Omo/Services/RecallNotificationScheduler.swift new file mode 100644 index 0000000..13f68e5 --- /dev/null +++ b/Omo/Omo/Services/RecallNotificationScheduler.swift @@ -0,0 +1,63 @@ +import Foundation +import UserNotifications + +protocol RecallNotificationScheduling: Sendable { + func schedule(_ card: MemoryCard) async throws + func cancel(cardID: String) async +} + +struct RecallNotificationPlan: Equatable, Sendable { + static let minimumLeadTime: TimeInterval = 60 + + let identifier: String + let title: String + let body: String + let userInfo: [String: String] + let triggerDate: Date + + init(card: MemoryCard, now: Date = Date()) { + identifier = "omo.recall.\(card.id)" + title = "你还记得吗?" + body = card.recallCue + userInfo = ["cardID": card.id] + let dueDate = try? Date(card.nextReviewAt, strategy: .iso8601) + triggerDate = max(dueDate ?? now, now.addingTimeInterval(Self.minimumLeadTime)) + } +} + +struct LocalRecallNotificationScheduler: RecallNotificationScheduling, @unchecked Sendable { + private let center: UNUserNotificationCenter + + init(center: UNUserNotificationCenter = .current()) { + self.center = center + } + + func schedule(_ card: MemoryCard) async throws { + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + guard granted else { return } + + let plan = RecallNotificationPlan(card: card) + let content = UNMutableNotificationContent() + content.title = plan.title + content.body = plan.body + content.sound = .default + content.userInfo = plan.userInfo + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: max(1, plan.triggerDate.timeIntervalSinceNow), + repeats: false + ) + center.removePendingNotificationRequests(withIdentifiers: [plan.identifier]) + try await center.add( + UNNotificationRequest( + identifier: plan.identifier, + content: content, + trigger: trigger + ) + ) + } + + func cancel(cardID: String) async { + center.removePendingNotificationRequests(withIdentifiers: ["omo.recall.\(cardID)"]) + center.removeDeliveredNotifications(withIdentifiers: ["omo.recall.\(cardID)"]) + } +} diff --git a/Omo/Omo/Services/ScreenshotJobCache.swift b/Omo/Omo/Services/ScreenshotJobCache.swift new file mode 100644 index 0000000..7209b6f --- /dev/null +++ b/Omo/Omo/Services/ScreenshotJobCache.swift @@ -0,0 +1,102 @@ +import Foundation + +protocol ScreenshotJobPersisting: Sendable { + func loadJobs() async throws -> [ScreenshotJob] + func save(job: ScreenshotJob, imageData: Data) async throws + func update(job: ScreenshotJob) async throws + func imageData(for jobID: String) async throws -> Data? + func removeImage(for jobID: String) async throws + func removeJob(for jobID: String) async throws +} + +actor ScreenshotJobCache: ScreenshotJobPersisting { + private let directory: URL + private let metadataURL: URL + private let fileManager: FileManager + + init( + directory: URL? = nil, + fileManager: FileManager = .default + ) { + self.fileManager = fileManager + let root = directory ?? fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + .appending(path: "Omo/ScreenshotJobs", directoryHint: .isDirectory) + self.directory = root + metadataURL = root.appending(path: "jobs.json", directoryHint: .notDirectory) + } + + func loadJobs() throws -> [ScreenshotJob] { + guard fileManager.fileExists(atPath: metadataURL.path) else { return [] } + return try JSONDecoder().decode( + [ScreenshotJob].self, + from: Data(contentsOf: metadataURL) + ) + } + + func save(job: ScreenshotJob, imageData: Data) throws { + try ensureDirectory() + try imageData.write(to: imageURL(for: job.id), options: .atomic) + try applyFileProtection(to: imageURL(for: job.id)) + try update(job: job) + } + + func update(job: ScreenshotJob) throws { + try ensureDirectory() + var jobs = try loadJobs() + if let index = jobs.firstIndex(where: { $0.id == job.id }) { + jobs[index] = job + } else { + jobs.append(job) + } + jobs.sort { $0.createdAt > $1.createdAt } + let data = try JSONEncoder().encode(jobs) + try data.write(to: metadataURL, options: .atomic) + try applyFileProtection(to: metadataURL) + } + + func imageData(for jobID: String) throws -> Data? { + let url = imageURL(for: jobID) + guard fileManager.fileExists(atPath: url.path) else { return nil } + return try Data(contentsOf: url) + } + + func removeImage(for jobID: String) throws { + let url = imageURL(for: jobID) + guard fileManager.fileExists(atPath: url.path) else { return } + try fileManager.removeItem(at: url) + } + + func removeJob(for jobID: String) throws { + try removeImage(for: jobID) + var jobs = try loadJobs() + jobs.removeAll { $0.id == jobID } + try ensureDirectory() + let data = try JSONEncoder().encode(jobs) + try data.write(to: metadataURL, options: .atomic) + try applyFileProtection(to: metadataURL) + } + + private func ensureDirectory() throws { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + try applyFileProtection(to: directory) + } + + private func imageURL(for jobID: String) -> URL { + directory.appending(path: "\(jobID).jpg", directoryHint: .notDirectory) + } + + private func applyFileProtection(to url: URL) throws { + #if os(iOS) + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + #endif + } +} diff --git a/Omo/Omo/Services/ScreenshotUploadCoordinator.swift b/Omo/Omo/Services/ScreenshotUploadCoordinator.swift new file mode 100644 index 0000000..c602b75 --- /dev/null +++ b/Omo/Omo/Services/ScreenshotUploadCoordinator.swift @@ -0,0 +1,60 @@ +import Combine +import Foundation + +@MainActor +final class ScreenshotUploadCoordinator: ObservableObject { + enum Phase: Equatable { + case idle + case awaitingConsent + case submitting + } + + @Published private(set) var phase: Phase = .idle + @Published private(set) var errorMessage = "" + private var pendingImageData: Data? + + var needsConsent: Bool { phase == .awaitingConsent } + var isSubmitting: Bool { phase == .submitting } + + func receive(_ data: Data, hasConsent: Bool) { + guard !data.isEmpty else { + errorMessage = "无法读取这张图片。" + phase = .idle + return + } + errorMessage = "" + pendingImageData = data + phase = hasConsent ? .submitting : .awaitingConsent + } + + func submitReceived( + using submit: (Data) async -> Bool + ) async -> Bool { + guard phase == .submitting, let data = pendingImageData else { return false } + return await finishSubmission(data, using: submit) + } + + func confirmConsent( + using submit: (Data) async -> Bool + ) async -> Bool { + guard phase == .awaitingConsent, let data = pendingImageData else { return false } + phase = .submitting + return await finishSubmission(data, using: submit) + } + + func cancelConsent() { + pendingImageData = nil + phase = .idle + } + + private func finishSubmission( + _ data: Data, + using submit: (Data) async -> Bool + ) async -> Bool { + let accepted = await submit(data) + if !accepted { errorMessage = "截图任务未能接收,请重试。" } + pendingImageData = nil + phase = .idle + return accepted + } +} diff --git a/Omo/Omo/en.lproj/InfoPlist.strings b/Omo/Omo/en.lproj/InfoPlist.strings index 3f5299a..414518c 100644 --- a/Omo/Omo/en.lproj/InfoPlist.strings +++ b/Omo/Omo/en.lproj/InfoPlist.strings @@ -1,2 +1,4 @@ "CFBundleDisplayName" = "Omo"; "CFBundleName" = "Omo"; +"NSMicrophoneUsageDescription" = "Use your voice to describe the knowledge you want to find."; +"NSSpeechRecognitionUsageDescription" = "Turn your voice into a knowledge library search."; diff --git a/Omo/Omo/zh-Hans.lproj/InfoPlist.strings b/Omo/Omo/zh-Hans.lproj/InfoPlist.strings index 5651dfe..c5c90dd 100644 --- a/Omo/Omo/zh-Hans.lproj/InfoPlist.strings +++ b/Omo/Omo/zh-Hans.lproj/InfoPlist.strings @@ -1,2 +1,4 @@ "CFBundleDisplayName" = "哦莫"; "CFBundleName" = "哦莫"; +"NSMicrophoneUsageDescription" = "用语音描述你想找的知识。"; +"NSSpeechRecognitionUsageDescription" = "把你的语音转成知识库搜索文字。"; diff --git a/Omo/OmoTests/APIClientDecodingTests.swift b/Omo/OmoTests/APIClientDecodingTests.swift index 719813c..e78b853 100644 --- a/Omo/OmoTests/APIClientDecodingTests.swift +++ b/Omo/OmoTests/APIClientDecodingTests.swift @@ -2,6 +2,143 @@ import XCTest @testable import Omo final class APIClientDecodingTests: XCTestCase { + func testScreenshotSubmissionUsesDurableJobEndpointAndDecodesAcceptedState() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [APIClientURLProtocolStub.self] + let session = URLSession(configuration: configuration) + APIClientURLProtocolStub.handler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/screenshot-jobs") + XCTAssertEqual(request.timeoutInterval, 30) + let body = try requestBodyData(request) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: String] + ) + XCTAssertEqual(object["mimeType"], "image/jpeg") + XCTAssertEqual(object["imageBase64"], Data("image".utf8).base64EncodedString()) + let response = try XCTUnwrap( + HTTPURLResponse( + url: request.url!, + statusCode: 202, + httpVersion: nil, + headerFields: ["content-type": "application/json"] + ) + ) + let data = #"{"job":{"id":"job-1","state":"accepted","createdAt":"2026-08-08T00:00:00Z","updatedAt":"2026-08-08T00:00:00Z","attemptCount":0,"cardId":"","errorCode":"","errorMessage":"","retryable":false}}"#.data(using: .utf8)! + return (response, data) + } + defer { APIClientURLProtocolStub.handler = nil } + + let job = try await APIClient( + baseURL: URL(string: "https://omo-testflight-staging.example.com")!, + session: session + ).createScreenshotJob(from: Data("image".utf8)) + + XCTAssertEqual(job.id, "job-1") + XCTAssertEqual(job.state, .accepted) + XCTAssertTrue(job.isActive) + XCTAssertFalse(job.canRetry) + } + + func testAIProcessingConsentRequiresPromptUntilExplicitlyGranted() { + XCTAssertTrue(AIProcessingConsent.requiresPrompt(hasConsent: false)) + XCTAssertFalse(AIProcessingConsent.requiresPrompt(hasConsent: true)) + XCTAssertEqual(AIProcessingConsent.defaultsKey, "omo.ai-processing-consent.v1") + } + + func testKnowledgeLibrarySearchSendsOnlyQueryWithDeviceIDAndDecodesOrderedIDs() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [APIClientURLProtocolStub.self] + let session = URLSession(configuration: configuration) + APIClientURLProtocolStub.handler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/memory-cards/search") + XCTAssertFalse((request.value(forHTTPHeaderField: "X-Device-Id") ?? "").isEmpty) + let body = try requestBodyData(request) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: String] + ) + XCTAssertEqual(object, ["query": "如何避免认知卸载"]) + let response = try XCTUnwrap( + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["content-type": "application/json"] + ) + ) + return (response, #"{"orderedCardIDs":["card-b","card-a"]}"#.data(using: .utf8)!) + } + defer { APIClientURLProtocolStub.handler = nil } + + let result = try await APIClient( + baseURL: URL(string: "https://omo-testflight-staging.example.com")!, + session: session + ).searchKnowledgeLibrary(query: "如何避免认知卸载") + + XCTAssertEqual(result.orderedCardIDs, ["card-b", "card-a"]) + } + + func testReleaseEnvironmentRejectsMissingAPIURLInsteadOfFallingBackToProduction() { + XCTAssertThrowsError( + try AppEnvironment.resolveAPIBaseURL( + infoDictionary: [:], + processEnvironment: [:], + allowsDebugLocalhostFallback: false + ) + ) { error in + XCTAssertEqual(error as? AppEnvironmentError, .missingAPIBaseURL) + } + } + + func testReleaseEnvironmentAcceptsInjectedHTTPSStagingURL() throws { + for value in [ + "https://omo-testflight-staging.example.com", + "https://omo-testflight-staging-production.up.railway.app" + ] { + let url = try AppEnvironment.resolveAPIBaseURL( + infoDictionary: ["OmoAPIBaseURL": value], + processEnvironment: [:], + allowsDebugLocalhostFallback: false + ) + + XCTAssertEqual(url.absoluteString, value) + } + } + + func testReleaseEnvironmentRejectsInsecureAndLegacyProductionURLs() { + for value in [ + "http://staging.example.com", + "https://shibei-production.up.railway.app" + ] { + XCTAssertThrowsError( + try AppEnvironment.resolveAPIBaseURL( + infoDictionary: ["OmoAPIBaseURL": value], + processEnvironment: [:], + allowsDebugLocalhostFallback: false + ) + ) { error in + XCTAssertEqual(error as? AppEnvironmentError, .invalidAPIBaseURL) + } + } + } + + func testDebugEnvironmentAllowsExplicitOverrideAndLocalFallback() throws { + let override = try AppEnvironment.resolveAPIBaseURL( + infoDictionary: [:], + processEnvironment: ["OMO_API_BASE_URL": "http://127.0.0.1:9999"], + allowsDebugLocalhostFallback: true + ) + let fallback = try AppEnvironment.resolveAPIBaseURL( + infoDictionary: [:], + processEnvironment: [:], + allowsDebugLocalhostFallback: true + ) + + XCTAssertEqual(override.absoluteString, "http://127.0.0.1:9999") + XCTAssertEqual(fallback.absoluteString, "http://127.0.0.1:5174") + } + func testMemoryCardDecodesFromMinimalAPIContract() throws { let data = #"{"id":"card-1","coreKnowledge":"知识点","recallCue":"提示","answer":"答案","explanation":"解释","sourceTitle":"截图","rarity":"SR","createdAt":"2026-07-29T00:00:00Z","masteryStage":"sealed","nextReviewAt":"2026-07-29T00:00:00Z","reviewCount":0,"successfulRecallCount":0,"lastAssessment":null}"#.data(using: .utf8)! @@ -10,5 +147,90 @@ final class APIClientDecodingTests: XCTestCase { XCTAssertEqual(card.id, "card-1") XCTAssertEqual(card.rarity, "SR") XCTAssertEqual(card.masteryTitle, "封存") + XCTAssertNil(card.hiddenSemantic) + XCTAssertFalse(card.isRecallEligible) } + + func testMemoryCardDecodesHiddenSemanticAndBuildsExactSegments() throws { + let data = #"{"id":"card-2","coreKnowledge":"截图可能触发认知卸载。","hiddenSemantic":"认知卸载","recallCue":"为什么截图会影响记忆?","answer":"认知卸载","explanation":"设备替代了主动编码。","sourceTitle":"截图","rarity":"R","createdAt":"2026-07-29T00:00:00Z","masteryStage":"sealed","nextReviewAt":"2026-07-29T00:00:00Z","reviewCount":0,"successfulRecallCount":0,"lastAssessment":null}"#.data(using: .utf8)! + + let card = try JSONDecoder().decode(MemoryCard.self, from: data) + + XCTAssertTrue(card.isRecallEligible) + XCTAssertEqual( + card.knowledgeSegments, + RecallKnowledgeSegments(prefix: "截图可能触发", semantic: "认知卸载", suffix: "。") + ) + } + + @MainActor + func testRecallDeckIsEligibleDueAndLimitedToTenCards() throws { + let store = OmoStore() + store.cards = try (0..<12).map { try makeCard(id: "valid-\($0)", hiddenSemantic: "知识") } + store.cards.append(try makeCard(id: "legacy", hiddenSemantic: nil)) + + XCTAssertEqual(store.nextRecallDeck.count, 10) + XCTAssertTrue(store.nextRecallDeck.allSatisfy(\.isRecallEligible)) + XCTAssertFalse(store.nextRecallDeck.contains { $0.id == "legacy" }) + } + + private func makeCard(id: String, hiddenSemantic: String?) throws -> MemoryCard { + var object: [String: Any] = [ + "id": id, + "coreKnowledge": "完整知识", + "recallCue": "提示", + "answer": hiddenSemantic ?? "旧答案", + "explanation": "解释", + "sourceTitle": "截图", + "rarity": "R", + "createdAt": "2020-01-01T00:00:00Z", + "masteryStage": "sealed", + "nextReviewAt": "2020-01-01T00:00:00Z", + "reviewCount": 0, + "successfulRecallCount": 0, + "lastAssessment": NSNull() + ] + if let hiddenSemantic { object["hiddenSemantic"] = hiddenSemantic } + return try JSONDecoder().decode( + MemoryCard.self, + from: JSONSerialization.data(withJSONObject: object) + ) + } +} + +private func requestBodyData(_ request: URLRequest) throws -> Data { + if let body = request.httpBody { return body } + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { throw try XCTUnwrap(stream.streamError) } + if count == 0 { break } + data.append(buffer, count: count) + } + return data +} + +private final class APIClientURLProtocolStub: URLProtocol, @unchecked Sendable { + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + do { + let handler = try XCTUnwrap(Self.handler) + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} } diff --git a/Omo/OmoTests/KnowledgeLibraryPaginationTests.swift b/Omo/OmoTests/KnowledgeLibraryPaginationTests.swift new file mode 100644 index 0000000..1f2d1b3 --- /dev/null +++ b/Omo/OmoTests/KnowledgeLibraryPaginationTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import Omo + +final class KnowledgeLibraryPaginationTests: XCTestCase { + func testPaginatorFillsShorterColumnWithoutReordering() { + let pages = KnowledgeLibraryPaginator().pages( + itemHeights: [("a", 120), ("b", 180), ("c", 100), ("d", 80)], + availableHeight: 300, + verticalSpacing: 18 + ) + + XCTAssertEqual(pages.count, 1) + XCTAssertEqual(pages[0].readingOrder, ["a", "b", "c", "d"]) + XCTAssertEqual(pages[0].placements.map(\.column), [0, 1, 0, 1]) + XCTAssertEqual(pages[0].placements.map(\.y), [0, 0, 138, 198]) + } + + func testPaginatorStartsNewPageBeforeEitherColumnOverflows() { + let pages = KnowledgeLibraryPaginator().pages( + itemHeights: [("a", 180), ("b", 190), ("c", 130), ("d", 120)], + availableHeight: 300, + verticalSpacing: 18 + ) + + XCTAssertEqual(pages.count, 2) + XCTAssertEqual(pages[0].readingOrder, ["a", "b"]) + XCTAssertEqual(pages[1].readingOrder, ["c", "d"]) + } + + func testSingleOversizedCardStillGetsItsOwnPage() { + let pages = KnowledgeLibraryPaginator().pages( + itemHeights: [("large", 420), ("small", 90)], + availableHeight: 300, + verticalSpacing: 18 + ) + + XCTAssertEqual(pages.count, 2) + XCTAssertEqual(pages[0].readingOrder, ["large"]) + XCTAssertEqual(pages[0].placements[0].height, 420) + XCTAssertEqual(pages[1].readingOrder, ["small"]) + } + + func testLargerMeasuredHeightsProduceMorePages() { + let paginator = KnowledgeLibraryPaginator() + let compact = paginator.pages( + itemHeights: [("a", 80), ("b", 80), ("c", 80), ("d", 80), ("e", 80)], + availableHeight: 300, + verticalSpacing: 18 + ) + let accessibility = paginator.pages( + itemHeights: [("a", 180), ("b", 180), ("c", 180), ("d", 180), ("e", 180)], + availableHeight: 300, + verticalSpacing: 18 + ) + + XCTAssertGreaterThan(accessibility.count, compact.count) + XCTAssertEqual(accessibility.flatMap(\.readingOrder), ["a", "b", "c", "d", "e"]) + } + + func testAccessibilitySingleColumnPreservesReadableOrder() { + let pages = KnowledgeLibraryPaginator().pages( + itemHeights: [("a", 180), ("b", 180), ("c", 180)], + availableHeight: 300, + verticalSpacing: 18, + columnCount: 1 + ) + + XCTAssertEqual(pages.count, 3) + XCTAssertEqual(pages.flatMap(\.readingOrder), ["a", "b", "c"]) + XCTAssertTrue(pages.flatMap(\.placements).allSatisfy { $0.column == 0 }) + } + + func testEmptyInputProducesNoPages() { + XCTAssertTrue( + KnowledgeLibraryPaginator().pages( + itemHeights: [], + availableHeight: 300, + verticalSpacing: 18 + ).isEmpty + ) + } +} diff --git a/Omo/OmoTests/KnowledgeLibrarySearchTests.swift b/Omo/OmoTests/KnowledgeLibrarySearchTests.swift new file mode 100644 index 0000000..35060b9 --- /dev/null +++ b/Omo/OmoTests/KnowledgeLibrarySearchTests.swift @@ -0,0 +1,292 @@ +import XCTest +@testable import Omo + +final class KnowledgeLibrarySearchTests: XCTestCase { + @MainActor + func testDefaultDependencyUsesAPIWhileExplicitFixtureUsesMock() { + let live = KnowledgeLibraryDependencies.makeSearcher(arguments: ["Omo"]) + let fixture = KnowledgeLibraryDependencies.makeSearcher( + arguments: ["Omo", "-OmoLibraryMockSearch"] + ) + + XCTAssertTrue(live is APIKnowledgeLibrarySearcher) + XCTAssertTrue(fixture is DebugMockKnowledgeLibrarySearcher) + } + + func testDebugMockSearchRequiresExplicitFixtureArgument() { + XCTAssertFalse(KnowledgeLibraryDebugConfiguration.current(arguments: []).usesMockSearch) + XCTAssertTrue( + KnowledgeLibraryDebugConfiguration.current( + arguments: ["Omo", "-OmoLibraryMockSearch"] + ).usesMockSearch + ) + } + + @MainActor + func testBlankQueryRestoresAllCardsAndCancelsSearch() async { + let cards = [testCard("a", "认知卸载"), testCard("b", "提取练习")] + let searcher = SearchStub { request in + try? await Task.sleep(for: .milliseconds(100)) + return .init(orderedCardIDs: [request.candidates[0].id]) + } + let model = KnowledgeLibraryViewModel(cards: cards, searcher: searcher) + + model.query = "认知" + model.submit() + model.query = " " + model.submit() + await Task.yield() + + XCTAssertEqual(model.state, .all) + XCTAssertEqual(model.visibleCards, cards) + XCTAssertEqual(model.currentPage, 0) + } + + @MainActor + func testLatestRequestWinsWhenOlderResponseFinishesLast() async { + let cards = [testCard("old", "旧结果"), testCard("new", "新结果")] + let searcher = SearchStub { request in + if request.query == "旧" { + try? await Task.sleep(for: .milliseconds(180)) + return .init(orderedCardIDs: ["old"]) + } + try? await Task.sleep(for: .milliseconds(20)) + return .init(orderedCardIDs: ["new"]) + } + let model = KnowledgeLibraryViewModel(cards: cards, searcher: searcher) + + model.query = "旧" + model.submit() + model.query = "新" + model.submit() + try? await Task.sleep(for: .milliseconds(240)) + + XCTAssertEqual(model.state, .results) + XCTAssertEqual(model.visibleCards.map(\.id), ["new"]) + } + + @MainActor + func testResultMappingDropsUnknownAndDuplicateIDs() async { + let cards = [testCard("a", "甲"), testCard("b", "乙")] + let searcher = SearchStub { _ in + .init(orderedCardIDs: ["b", "missing", "b", "a"]) + } + let model = KnowledgeLibraryViewModel(cards: cards, searcher: searcher) + + model.query = "知识" + model.submit() + await model.waitForSearchForTesting() + + XCTAssertEqual(model.state, .results) + XCTAssertEqual(model.visibleCards.map(\.id), ["b", "a"]) + } + + @MainActor + func testEmptyResponseAndFailureRemainDistinct() async { + let card = testCard("a", "甲") + let empty = KnowledgeLibraryViewModel( + cards: [card], + searcher: SearchStub { _ in .init(orderedCardIDs: []) } + ) + empty.query = "没有" + empty.submit() + await empty.waitForSearchForTesting() + + XCTAssertEqual(empty.state, .noResults) + XCTAssertTrue(empty.visibleCards.isEmpty) + + let failed = KnowledgeLibraryViewModel( + cards: [card], + searcher: SearchStub { _ in throw KnowledgeLibrarySearchError.unavailable } + ) + failed.query = "失败" + failed.submit() + await failed.waitForSearchForTesting() + + XCTAssertEqual(failed.state, .failed(message: "暂时无法搜索,请稍后重试。")) + XCTAssertEqual(failed.query, "失败") + } + + @MainActor + func testServiceFailureDoesNotLeavePreviousResultsVisible() async { + let attempts = AttemptCounter() + let card = testCard("a", "认知卸载") + let model = KnowledgeLibraryViewModel( + cards: [card], + searcher: SearchStub { _ in + if await attempts.increment() == 1 { + return .init(orderedCardIDs: ["a"]) + } + throw KnowledgeLibrarySearchError.unavailable + } + ) + + model.query = "第一次" + model.submit() + await model.waitForSearchForTesting() + XCTAssertEqual(model.visibleCards.map(\.id), ["a"]) + + model.query = "第二次" + model.submit() + await model.waitForSearchForTesting() + + XCTAssertTrue(model.visibleCards.isEmpty) + XCTAssertEqual(model.state, .failed(message: "暂时无法搜索,请稍后重试。")) + } + + @MainActor + func testRetryPreservesQuery() async { + let attempts = AttemptCounter() + let model = KnowledgeLibraryViewModel( + cards: [testCard("a", "认知卸载")], + searcher: SearchStub { _ in + if await attempts.increment() == 1 { + throw KnowledgeLibrarySearchError.unavailable + } + return .init(orderedCardIDs: ["a"]) + } + ) + + model.query = "认知" + model.submit() + await model.waitForSearchForTesting() + model.retry() + await model.waitForSearchForTesting() + + XCTAssertEqual(model.query, "认知") + XCTAssertEqual(model.state, .results) + XCTAssertEqual(model.visibleCards.map(\.id), ["a"]) + } + + @MainActor + func testFinalVoiceTranscriptUpdatesQueryAndSubmitsExactlyOnce() async { + let attempts = AttemptCounter() + let speech = SpeechStub() + let model = KnowledgeLibraryViewModel( + cards: [testCard("a", "认知卸载")], + searcher: SearchStub { _ in + _ = await attempts.increment() + return .init(orderedCardIDs: ["a"]) + }, + speechTranscriber: speech + ) + + await model.startOrStopVoice() + speech.send(.transcript("如何避免认知卸载", isFinal: true)) + await Task.yield() + await model.waitForSearchForTesting() + + let attemptCount = await attempts.current + XCTAssertEqual(model.query, "如何避免认知卸载") + XCTAssertEqual(attemptCount, 1) + XCTAssertEqual(model.state, .results) + } + + @MainActor + func testVoicePermissionFailureDoesNotEraseTypedQuery() async { + let speech = SpeechStub() + let model = KnowledgeLibraryViewModel( + cards: [testCard("a", "认知卸载")], + searcher: SearchStub { _ in .init(orderedCardIDs: []) }, + speechTranscriber: speech + ) + model.query = "已经输入的内容" + + await model.startOrStopVoice() + speech.send(.denied) + await model.waitForSpeechStateForTesting(.denied) + + XCTAssertEqual(model.query, "已经输入的内容") + XCTAssertEqual(model.speechState, .denied) + } + + @MainActor + func testDisappearStopsListening() async { + let speech = SpeechStub() + let model = KnowledgeLibraryViewModel( + cards: [testCard("a", "认知卸载")], + searcher: SearchStub { _ in .init(orderedCardIDs: []) }, + speechTranscriber: speech + ) + + await model.startOrStopVoice() + model.onDisappear() + + XCTAssertEqual(speech.stopCount, 1) + } + +} + +private struct SearchStub: KnowledgeLibrarySearching { + let handler: @Sendable (KnowledgeLibrarySearchRequest) async throws -> KnowledgeLibrarySearchResponse + + init(_ handler: @escaping @Sendable (KnowledgeLibrarySearchRequest) async throws -> KnowledgeLibrarySearchResponse) { + self.handler = handler + } + + func search(_ request: KnowledgeLibrarySearchRequest) async throws -> KnowledgeLibrarySearchResponse { + try await handler(request) + } +} + +private actor AttemptCounter { + private var value = 0 + + func increment() -> Int { + value += 1 + return value + } + + var current: Int { value } +} + +@MainActor +private final class SpeechStub: KnowledgeLibrarySpeechTranscribing { + let events: AsyncStream + private var continuation: AsyncStream.Continuation? + private(set) var stopCount = 0 + + init() { + var continuation: AsyncStream.Continuation? + events = AsyncStream { continuation = $0 } + self.continuation = continuation + } + + func start() async { + continuation?.yield(.listening) + } + + func stop() { + stopCount += 1 + continuation?.yield(.stopped) + } + + func send(_ event: KnowledgeLibrarySpeechEvent) { + continuation?.yield(event) + } +} + +private func testCard(_ id: String, _ knowledge: String) -> MemoryCard { + MemoryCard( + id: id, + coreKnowledge: knowledge, + hiddenSemantic: nil, + recallCue: "你还记得什么?", + answer: knowledge, + explanation: "合成测试解释", + sourceTitle: "合成测试来源", + sourceAccount: nil, + sourcePlatform: nil, + sourceUrl: nil, + sourceStatus: "screenshot_only", + sourceProvider: nil, + sourceConfidence: nil, + rarity: "R", + createdAt: "2026-08-03T00:00:00Z", + masteryStage: "sealed", + nextReviewAt: "2026-08-03T00:00:00Z", + reviewCount: 0, + successfulRecallCount: 0, + lastAssessment: nil + ) +} diff --git a/Omo/OmoTests/RecallInteractionStateTests.swift b/Omo/OmoTests/RecallInteractionStateTests.swift new file mode 100644 index 0000000..d9c6776 --- /dev/null +++ b/Omo/OmoTests/RecallInteractionStateTests.swift @@ -0,0 +1,465 @@ +import XCTest +import UIKit +@testable import Omo + +final class RecallInteractionStateTests: XCTestCase { + @MainActor + func testUploadCoordinatorUsesOneConsentAndSubmissionStateMachine() async { + let coordinator = ScreenshotUploadCoordinator() + let image = Data([1, 2, 3]) + var submitted = Data() + + coordinator.receive(image, hasConsent: false) + XCTAssertEqual(coordinator.phase, .awaitingConsent) + + let accepted = await coordinator.confirmConsent { data in + submitted = data + return true + } + + XCTAssertTrue(accepted) + XCTAssertEqual(submitted, image) + XCTAssertEqual(coordinator.phase, .idle) + } + + @MainActor + func testLoadFailureIsExplicitAndRetryCanRecoverWithoutBlockingNavigation() async { + let directory = temporaryJobDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let api = RecoverableLoadAPI() + let store = OmoStore( + api: api, + notificationScheduler: NotificationSchedulerSpy(), + screenshotJobCache: ScreenshotJobCache(directory: directory) + ) + + await store.load() + XCTAssertEqual(store.loadState, .failed("暂时无法连接测试服务。")) + + await api.recover() + await store.load() + XCTAssertEqual(store.loadState, .loaded) + } + + @MainActor + func testSlowScreenshotAcceptanceKeepsOptimisticTaskAndExistingRecallAvailable() async throws { + let directory = temporaryJobDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let dueCard = notificationTestCard() + let api = ControllableScreenshotAPI(cards: [dueCard]) + let store = OmoStore( + api: api, + notificationScheduler: NotificationSchedulerSpy(), + screenshotJobCache: ScreenshotJobCache(directory: directory) + ) + store.cards = [dueCard] + let imageData = try XCTUnwrap(UIImage(systemName: "circle")?.pngData()) + + let submission = Task { await store.createCard(from: imageData) } + await api.waitUntilCreationStarts() + + XCTAssertEqual(store.screenshotJobs.count, 1) + XCTAssertEqual(store.screenshotJobs[0].state, .accepted) + XCTAssertEqual(store.nextRecallDeck.map(\.id), [dueCard.id]) + + await api.finishCreation(with: makeScreenshotJob(id: store.screenshotJobs[0].id)) + let accepted = await submission.value + XCTAssertTrue(accepted) + } + + @MainActor + func testFailedScreenshotTaskSurvivesReloadAndRetriesWithCachedImage() async throws { + let directory = temporaryJobDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let failed = makeScreenshotJob( + id: "job-retry", + state: .failed, + attemptCount: 1, + errorCode: "model_timeout", + errorMessage: "截图处理超时,请重试。", + retryable: true + ) + let imageData = try XCTUnwrap(UIImage(systemName: "circle")?.pngData()) + let cache = ScreenshotJobCache(directory: directory) + try await cache.save(job: failed, imageData: imageData) + let api = ControllableScreenshotAPI(serverJobs: [failed]) + let store = OmoStore( + api: api, + notificationScheduler: NotificationSchedulerSpy(), + screenshotJobCache: cache + ) + + await store.load() + XCTAssertEqual(store.screenshotJobs, [failed]) + + let retried = await store.retryScreenshotJob(failed) + let retriedJobIDs = await api.retriedJobIDs() + XCTAssertTrue(retried) + XCTAssertEqual(store.screenshotJobs[0].state, .accepted) + XCTAssertEqual(retriedJobIDs, [failed.id]) + } + + @MainActor + func testReloadDeletesLocalRetryImageWhenServerAlreadySucceeded() async throws { + let directory = temporaryJobDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let card = notificationTestCard() + let succeeded = makeScreenshotJob( + id: "job-notification", + state: .succeeded, + cardId: card.id + ) + let imageData = try XCTUnwrap(UIImage(systemName: "circle")?.pngData()) + let cache = ScreenshotJobCache(directory: directory) + try await cache.save(job: succeeded, imageData: imageData) + let store = OmoStore( + api: OmoAPIStub(createdCard: card, assessedCard: card), + notificationScheduler: NotificationSchedulerSpy(), + screenshotJobCache: cache + ) + + await store.load() + + let retainedImage = try await cache.imageData(for: succeeded.id) + XCTAssertNil(retainedImage) + } + + func testNotificationPlanCarriesQuestionAndCardIDWithoutAnswerContent() { + let card = notificationTestCard() + let now = Date(timeIntervalSince1970: 1_700_000_000) + + let plan = RecallNotificationPlan(card: card, now: now) + + XCTAssertEqual(plan.title, "你还记得吗?") + XCTAssertEqual(plan.body, "为什么保存截图反而可能更难记住?") + XCTAssertEqual(plan.userInfo, ["cardID": "notification-card"]) + XCTAssertGreaterThan(plan.triggerDate, now) + XCTAssertFalse(plan.title.contains(card.answer)) + XCTAssertFalse(plan.body.contains(card.answer)) + XCTAssertFalse(plan.userInfo.values.contains(card.answer)) + XCTAssertFalse(plan.userInfo.values.contains(card.explanation)) + } + + @MainActor + func testNotificationRouteReturnsToTodayAndOverlaysTheMatchingCard() { + let card = notificationTestCard() + let store = OmoStore() + store.cards = [card] + store.selectedTab = .library + store.presentedCard = card + + store.handleRecallNotification(cardID: card.id) + + XCTAssertEqual(store.selectedTab, .today) + XCTAssertNil(store.presentedCard) + XCTAssertEqual(store.notificationRecallCard?.id, card.id) + XCTAssertNil(store.pendingRecallCardID) + } + + @MainActor + func testNotificationRouteWaitsForCardsThenSafelyDropsAnUnknownID() { + let card = notificationTestCard() + let store = OmoStore() + + store.handleRecallNotification(cardID: card.id) + XCTAssertEqual(store.pendingRecallCardID, card.id) + XCTAssertNil(store.notificationRecallCard) + + store.cards = [card] + store.resolvePendingRecallNotification() + XCTAssertEqual(store.notificationRecallCard?.id, card.id) + + store.notificationRecallCard = nil + store.handleRecallNotification(cardID: "missing-card") + store.resolvePendingRecallNotification() + XCTAssertNil(store.notificationRecallCard) + XCTAssertNil(store.pendingRecallCardID) + } + + @MainActor + func testCreateAssessAndDeleteKeepLocalRecallNotificationInSync() async throws { + let directory = temporaryJobDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let created = notificationTestCard(nextReviewAt: "2026-08-09T08:00:00Z") + let assessed = notificationTestCard( + nextReviewAt: "2026-08-12T08:00:00Z", + reviewCount: 1, + lastAssessment: .remembered + ) + let scheduler = NotificationSchedulerSpy() + let store = OmoStore( + api: OmoAPIStub(createdCard: created, assessedCard: assessed), + notificationScheduler: scheduler, + screenshotJobCache: ScreenshotJobCache(directory: directory) + ) + let imageData = try XCTUnwrap(UIImage(systemName: "circle")?.pngData()) + + let createdSuccessfully = await store.createCard(from: imageData) + await store.refreshScreenshotJobs() + XCTAssertTrue(createdSuccessfully) + _ = try await store.assess(created, as: .remembered) + await store.delete(assessed) + + let snapshot = await scheduler.snapshot() + XCTAssertEqual(snapshot.scheduled.map(\.nextReviewAt), [ + "2026-08-09T08:00:00Z", + "2026-08-12T08:00:00Z" + ]) + XCTAssertEqual(snapshot.cancelledCardIDs, [created.id]) + } + + func testScratchMustReachEightyPercentBeforeRatingAppears() { + var state = RecallRoundState(cardCount: 2) + + state.updateCoverage(0.79) + XCTAssertFalse(state.showsRating) + XCTAssertFalse(state.showsContext) + XCTAssertEqual(state.coverage, 0.79, accuracy: 0.001) + + state.updateCoverage(0.8) + XCTAssertTrue(state.showsRating) + XCTAssertTrue(state.showsContext) + XCTAssertEqual(state.coverage, 1) + } + + func testSuccessfulAssessmentAdvancesAndResealsNextCard() { + var state = RecallRoundState(cardCount: 2) + state.updateCoverage(1) + state.beginSubmission(.remembered) + state.finishSubmission() + + XCTAssertEqual(state.currentIndex, 1) + XCTAssertEqual(state.coverage, 0) + XCTAssertEqual(state.phase, .covered) + XCTAssertFalse(state.isComplete) + } + + func testLastAssessmentCompletesRound() { + var state = RecallRoundState(cardCount: 1) + state.updateCoverage(1) + state.beginSubmission(.forgot) + state.finishSubmission() + + XCTAssertTrue(state.isComplete) + } + + func testFailedAssessmentCanRetryWithoutAdvancing() { + var state = RecallRoundState(cardCount: 2) + state.updateCoverage(1) + state.beginSubmission(.fuzzy) + state.failSubmission() + + XCTAssertEqual(state.currentIndex, 0) + XCTAssertEqual(state.retryAssessment(), .fuzzy) + XCTAssertEqual(state.phase, .submitting(.fuzzy)) + } + + func testRatingScaleUsesLeftEdgeAsCancelAndThreeAssessmentNodes() { + XCTAssertNil(RecallRatingScale.nearestAssessment(at: 0)) + XCTAssertNil(RecallRatingScale.nearestAssessment(at: 0.20)) + XCTAssertEqual(RecallRatingScale.nearestAssessment(at: 0.42), .forgot) + XCTAssertEqual(RecallRatingScale.nearestAssessment(at: 0.70), .fuzzy) + XCTAssertEqual(RecallRatingScale.nearestAssessment(at: 0.97), .remembered) + XCTAssertEqual(RecallRatingScale.position(for: nil), 0) + XCTAssertEqual(RecallRatingScale.position(for: .forgot), 0.42) + XCTAssertEqual(RecallRatingScale.position(for: .fuzzy), 0.70) + XCTAssertEqual(RecallRatingScale.position(for: .remembered), 0.97) + } + + func testKnowledgeSegmentsSupportBeginningMiddleAndEndMatches() { + XCTAssertEqual( + RecallKnowledgeSegments.make(coreKnowledge: "主动提取能够暴露遗忘", hiddenSemantic: "主动提取"), + RecallKnowledgeSegments(prefix: "", semantic: "主动提取", suffix: "能够暴露遗忘") + ) + XCTAssertEqual( + RecallKnowledgeSegments.make(coreKnowledge: "熟悉感不等于真正掌握", hiddenSemantic: "不等于"), + RecallKnowledgeSegments(prefix: "熟悉感", semantic: "不等于", suffix: "真正掌握") + ) + XCTAssertEqual( + RecallKnowledgeSegments.make(coreKnowledge: "关键机制是认知卸载", hiddenSemantic: "认知卸载"), + RecallKnowledgeSegments(prefix: "关键机制是", semantic: "认知卸载", suffix: "") + ) + } + + func testKnowledgeSegmentsUseFirstExactMatchAndRejectInvalidValues() { + XCTAssertEqual( + RecallKnowledgeSegments.make(coreKnowledge: "提取后核对,再次提取", hiddenSemantic: "提取"), + RecallKnowledgeSegments(prefix: "", semantic: "提取", suffix: "后核对,再次提取") + ) + XCTAssertNil(RecallKnowledgeSegments.make(coreKnowledge: "认知卸载", hiddenSemantic: "认知 卸载")) + XCTAssertNil(RecallKnowledgeSegments.make(coreKnowledge: "认知卸载", hiddenSemantic: "")) + } +} + +private func notificationTestCard( + nextReviewAt: String = "2026-08-08T00:00:00Z", + reviewCount: Int = 0, + lastAssessment: MemoryAssessment? = nil +) -> MemoryCard { + MemoryCard( + id: "notification-card", + coreKnowledge: "截图可能削弱记忆,因为它会触发认知卸载。", + hiddenSemantic: "认知卸载", + recallCue: "为什么保存截图反而可能更难记住?", + answer: "认知卸载", + explanation: "设备替代了主动编码。", + sourceTitle: "合成测试来源", + sourceAccount: nil, + sourcePlatform: nil, + sourceUrl: nil, + sourceStatus: "screenshot_only", + sourceProvider: nil, + sourceConfidence: nil, + rarity: "R", + createdAt: "2026-08-08T00:00:00Z", + masteryStage: "sealed", + nextReviewAt: nextReviewAt, + reviewCount: reviewCount, + successfulRecallCount: 0, + lastAssessment: lastAssessment + ) +} + +private struct OmoAPIStub: OmoAPIProviding { + let createdCard: MemoryCard + let assessedCard: MemoryCard + + func cards() async throws -> [MemoryCard] { [createdCard] } + func createCard(from imageData: Data) async throws -> MemoryCard { createdCard } + func screenshotJobs() async throws -> [ScreenshotJob] { + [makeScreenshotJob(id: "job-notification", state: .succeeded, cardId: createdCard.id)] + } + func createScreenshotJob(from imageData: Data) async throws -> ScreenshotJob { + makeScreenshotJob(id: "job-notification", state: .succeeded, cardId: createdCard.id) + } + func screenshotJob(id: String) async throws -> ScreenshotJob { + makeScreenshotJob(id: id, state: .succeeded, cardId: createdCard.id) + } + func retryScreenshotJob(id: String, imageData: Data) async throws -> ScreenshotJob { + makeScreenshotJob(id: id) + } + func assess(_ card: MemoryCard, as assessment: MemoryAssessment) async throws -> MemoryCard { + assessedCard + } + func delete(_ card: MemoryCard) async throws {} +} + +private actor NotificationSchedulerSpy: RecallNotificationScheduling { + private var scheduled: [MemoryCard] = [] + private var cancelledCardIDs: [String] = [] + + func schedule(_ card: MemoryCard) async throws { + scheduled.append(card) + } + + func cancel(cardID: String) async { + cancelledCardIDs.append(cardID) + } + + func snapshot() -> (scheduled: [MemoryCard], cancelledCardIDs: [String]) { + (scheduled, cancelledCardIDs) + } +} + +private actor ControllableScreenshotAPI: OmoAPIProviding { + private let storedCards: [MemoryCard] + private var jobs: [ScreenshotJob] + private var creationContinuation: CheckedContinuation? + private var creationStarted = false + private var retries: [String] = [] + + init(cards: [MemoryCard] = [], serverJobs: [ScreenshotJob] = []) { + storedCards = cards + jobs = serverJobs + } + + func cards() async throws -> [MemoryCard] { storedCards } + + func createCard(from imageData: Data) async throws -> MemoryCard { + throw APIError.server("legacy creation should not be used") + } + + func screenshotJobs() async throws -> [ScreenshotJob] { jobs } + + func createScreenshotJob(from imageData: Data) async throws -> ScreenshotJob { + creationStarted = true + return try await withCheckedThrowingContinuation { continuation in + creationContinuation = continuation + } + } + + func screenshotJob(id: String) async throws -> ScreenshotJob { + jobs.first(where: { $0.id == id }) ?? makeScreenshotJob(id: id) + } + + func retryScreenshotJob(id: String, imageData: Data) async throws -> ScreenshotJob { + retries.append(id) + let accepted = makeScreenshotJob(id: id, state: .accepted, attemptCount: 1) + jobs = [accepted] + return accepted + } + + func assess(_ card: MemoryCard, as assessment: MemoryAssessment) async throws -> MemoryCard { + card + } + + func delete(_ card: MemoryCard) async throws {} + + func waitUntilCreationStarts() async { + while !creationStarted { await Task.yield() } + } + + func finishCreation(with job: ScreenshotJob) { + jobs = [job] + creationContinuation?.resume(returning: job) + creationContinuation = nil + } + + func retriedJobIDs() -> [String] { retries } +} + +private actor RecoverableLoadAPI: OmoAPIProviding { + private var shouldFail = true + + func cards() async throws -> [MemoryCard] { + if shouldFail { throw APIError.server("暂时无法连接测试服务。") } + return [] + } + + func screenshotJobs() async throws -> [ScreenshotJob] { + if shouldFail { throw APIError.server("暂时无法连接测试服务。") } + return [] + } + + func createCard(from imageData: Data) async throws -> MemoryCard { throw APIError.invalidResponse } + func assess(_ card: MemoryCard, as assessment: MemoryAssessment) async throws -> MemoryCard { card } + func delete(_ card: MemoryCard) async throws {} + func recover() { shouldFail = false } +} + +private func makeScreenshotJob( + id: String, + state: ScreenshotJobState = .accepted, + attemptCount: Int = 0, + errorCode: String = "", + errorMessage: String = "", + retryable: Bool = false, + cardId: String = "" +) -> ScreenshotJob { + ScreenshotJob( + id: id, + state: state, + createdAt: "2026-08-08T00:00:00Z", + updatedAt: "2026-08-08T00:00:00Z", + attemptCount: attemptCount, + cardId: cardId, + errorCode: errorCode, + errorMessage: errorMessage, + retryable: retryable + ) +} + +private func temporaryJobDirectory() -> URL { + FileManager.default.temporaryDirectory + .appending(path: "omo-store-jobs-\(UUID().uuidString)", directoryHint: .isDirectory) +} diff --git a/Omo/OmoTests/ScreenshotJobPersistenceTests.swift b/Omo/OmoTests/ScreenshotJobPersistenceTests.swift new file mode 100644 index 0000000..f7e1e5a --- /dev/null +++ b/Omo/OmoTests/ScreenshotJobPersistenceTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import Omo + +final class ScreenshotJobPersistenceTests: XCTestCase { + func testAcceptedJobAndRetryImageSurviveCacheRecreation() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "omo-job-cache-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let job = screenshotJob(state: .accepted) + let imageData = Data("durable-image".utf8) + + let first = ScreenshotJobCache(directory: directory) + try await first.save(job: job, imageData: imageData) + + let restarted = ScreenshotJobCache(directory: directory) + let restartedJobs = try await restarted.loadJobs() + let restartedImage = try await restarted.imageData(for: job.id) + XCTAssertEqual(restartedJobs, [job]) + XCTAssertEqual(restartedImage, imageData) + + try await restarted.removeImage(for: job.id) + let removedImage = try await restarted.imageData(for: job.id) + let preservedJobs = try await restarted.loadJobs() + XCTAssertNil(removedImage) + XCTAssertEqual(preservedJobs, [job]) + } +} + +private func screenshotJob(state: ScreenshotJobState) -> ScreenshotJob { + ScreenshotJob( + id: "job-durable", + state: state, + createdAt: "2026-08-08T00:00:00Z", + updatedAt: "2026-08-08T00:00:00Z", + attemptCount: 0, + cardId: "", + errorCode: "", + errorMessage: "", + retryable: false + ) +} diff --git a/Omo/OmoUITests/OmoCoreInteractionUITests.swift b/Omo/OmoUITests/OmoCoreInteractionUITests.swift new file mode 100644 index 0000000..5200947 --- /dev/null +++ b/Omo/OmoUITests/OmoCoreInteractionUITests.swift @@ -0,0 +1,288 @@ +import XCTest + +final class OmoCoreInteractionUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testEmptyHomeLibraryAndUploadAreBothReachable() { + let app = launch(arguments: ["-OmoLibraryFixture", "empty"]) + + XCTAssertTrue(app.buttons["打开知识库"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["上传第一张知识截屏"].exists) + attachScreenshot("01-empty-home", app: app) + app.buttons["打开知识库"].tap() + + XCTAssertTrue(app.buttons["返回首页"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["上传新的知识截屏"].exists) + attachScreenshot("02-empty-library", app: app) + } + + func testProcessingScreenshotKeepsHomeAndLibraryEntriesAvailable() { + let app = launch(arguments: [ + "-OmoLibraryFixture", "empty", + "-OmoScreenshotJobFixture", "active" + ]) + + XCTAssertTrue(app.buttons["打开菜单"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["打开知识库"].exists) + XCTAssertTrue(app.buttons["上传第一张知识截屏"].exists) + XCTAssertTrue(app.staticTexts["正在整理知识卡"].exists) + attachScreenshot("03-processing-home", app: app) + + app.buttons["打开知识库"].tap() + XCTAssertTrue(app.staticTexts["第一张知识卡正在整理"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["上传新的知识截屏"].exists) + attachScreenshot("04-processing-library", app: app) + } + + func testFailedScreenshotKeepsRecoveryAndNavigationAvailable() { + let app = launch(arguments: [ + "-OmoLibraryFixture", "empty", + "-OmoScreenshotJobFixture", "failed" + ]) + + XCTAssertTrue(app.buttons["整理失败,点此重试"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["打开知识库"].exists) + XCTAssertTrue(app.buttons["上传第一张知识截屏"].exists) + attachScreenshot("05-failed-home", app: app) + + app.buttons["打开知识库"].tap() + XCTAssertTrue(app.buttons["重试"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["返回首页"].exists) + XCTAssertTrue(app.buttons["上传新的知识截屏"].exists) + attachScreenshot("06-failed-library", app: app) + } + + func testRecallRoundKeepsScratchAndPersistentHomeActionsReachable() { + let app = launch(arguments: ["-OmoLibraryFixture", "many"]) + + let mascot = app.buttons["哦莫 记忆伙伴"] + XCTAssertTrue(mascot.waitForExistence(timeout: 3)) + mascot.tap() + + XCTAssertTrue(app.otherElements["被遮住的承重语义"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["打开知识库"].exists) + XCTAssertTrue(app.buttons["上传新的知识截屏"].exists) + attachScreenshot("07-recall-scratch", app: app) + } + + func testRevealedRecallShowsRatingWithoutHidingPersistentActions() { + let app = launch(arguments: [ + "-OmoLibraryFixture", "many", + "-OmoRecallRevealed" + ]) + + let mascot = app.buttons["哦莫 记忆伙伴"] + XCTAssertTrue(mascot.waitForExistence(timeout: 3)) + mascot.tap() + + XCTAssertTrue(app.sliders["memory-rating-slider"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["打开知识库"].exists) + XCTAssertTrue(app.buttons["上传新的知识截屏"].exists) + attachScreenshot("08-recall-rating", app: app) + } + + func testSingleCardRecallJourneyRevealsRatesAndReturnsToUsableHome() { + let app = launch(arguments: [ + "-OmoLibraryFixture", "single", + "-OmoAssessmentFixture", "success" + ]) + + let mascot = app.buttons["哦莫 记忆伙伴"] + XCTAssertTrue(mascot.waitForExistence(timeout: 3)) + mascot.tap() + + let scratch = app.otherElements["被遮住的承重语义"] + XCTAssertTrue(scratch.waitForExistence(timeout: 3)) + for y in [0.25, 0.75] { + let leading = scratch.coordinate(withNormalizedOffset: CGVector(dx: 0.04, dy: y)) + let trailing = scratch.coordinate(withNormalizedOffset: CGVector(dx: 0.96, dy: y)) + leading.press(forDuration: 0.05, thenDragTo: trailing) + } + + let rating = app.sliders["memory-rating-slider"] + XCTAssertTrue(rating.waitForExistence(timeout: 3)) + let start = rating.coordinate(withNormalizedOffset: CGVector(dx: 0.04, dy: 0.18)) + let remembered = rating.coordinate(withNormalizedOffset: CGVector(dx: 0.96, dy: 0.18)) + start.press(forDuration: 0.1, thenDragTo: remembered) + + XCTAssertTrue(rating.waitForNonExistence(timeout: 4)) + XCTAssertTrue(app.buttons["打开知识库"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["上传新的知识截屏"].exists) + XCTAssertFalse(app.otherElements["被遮住的承重语义"].exists) + attachScreenshot("09-recall-complete-home", app: app) + } + + func testEmptyUserCanVisitEveryAvailableSurfaceAndAlwaysReturnHome() { + let app = launch(arguments: ["-OmoLibraryFixture", "empty"]) + + app.buttons["打开菜单"].tap() + XCTAssertTrue(app.buttons["Profile"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["Settings"].exists) + app.buttons["Profile"].tap() + XCTAssertTrue(app.staticTexts["我的"].waitForExistence(timeout: 3)) + app.buttons["返回首页"].tap() + + XCTAssertTrue(app.buttons["打开菜单"].waitForExistence(timeout: 3)) + app.buttons["打开菜单"].tap() + app.buttons["Settings"].tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 3)) + app.buttons["完成"].tap() + + XCTAssertTrue(app.buttons["打开知识库"].waitForExistence(timeout: 3)) + app.buttons["打开知识库"].tap() + XCTAssertTrue(app.buttons["返回首页"].waitForExistence(timeout: 3)) + app.buttons["返回首页"].tap() + + let upload = app.buttons["上传第一张知识截屏"] + XCTAssertTrue(upload.waitForExistence(timeout: 3)) + upload.tap() + let cancel = app.buttons.matching( + NSPredicate(format: "label IN %@", ["Cancel", "取消"]) + ).firstMatch + XCTAssertTrue(cancel.waitForExistence(timeout: 5)) + cancel.tap() + + XCTAssertTrue(app.buttons["打开菜单"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["打开知识库"].exists) + XCTAssertTrue(app.buttons["上传第一张知识截屏"].exists) + attachScreenshot("10-empty-journey-returned-home", app: app) + } + + func testProcessingUserCanNavigateAndCancelAnotherUploadWithoutLosingTask() { + let app = launch(arguments: [ + "-OmoLibraryFixture", "empty", + "-OmoScreenshotJobFixture", "active" + ]) + + XCTAssertTrue(app.staticTexts["正在整理知识卡"].waitForExistence(timeout: 3)) + app.buttons["打开知识库"].tap() + XCTAssertTrue(app.staticTexts["第一张知识卡正在整理"].waitForExistence(timeout: 3)) + app.buttons["返回首页"].tap() + + let upload = app.buttons["上传第一张知识截屏"] + XCTAssertTrue(upload.waitForExistence(timeout: 3)) + upload.tap() + let cancel = app.buttons.matching( + NSPredicate(format: "label IN %@", ["Cancel", "取消"]) + ).firstMatch + XCTAssertTrue(cancel.waitForExistence(timeout: 5)) + cancel.tap() + + XCTAssertTrue(app.staticTexts["正在整理知识卡"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["打开菜单"].exists) + XCTAssertTrue(app.buttons["打开知识库"].exists) + XCTAssertTrue(app.buttons["上传第一张知识截屏"].exists) + attachScreenshot("11-processing-journey-returned-home", app: app) + } + + #if OMO_STAGING_UI + func testRealStagingFirstScreenshotSurvivesRelaunchAndCompletesRecall() { + let app = XCUIApplication() + app.launchArguments = ["-OmoSkipLaunch"] + app.launchEnvironment["OMO_API_BASE_URL"] = "https://omo-api-staging-staging.up.railway.app" + app.launch() + + let upload = app.buttons["上传第一张知识截屏"] + XCTAssertTrue(upload.waitForExistence(timeout: 15)) + upload.tap() + + let firstPhoto = app.images.matching( + NSPredicate(format: "identifier == %@", "PXGGridLayout-Info") + ).firstMatch + XCTAssertTrue(firstPhoto.waitForExistence(timeout: 10)) + firstPhoto.tap() + let add = app.buttons.matching( + NSPredicate(format: "label IN %@", ["Add", "添加"]) + ).firstMatch + if add.waitForExistence(timeout: 2) { add.tap() } + + let consent = app.buttons["同意并生成"] + XCTAssertTrue(consent.waitForExistence(timeout: 10)) + consent.tap() + + XCTAssertTrue(app.buttons["打开菜单"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["打开知识库"].exists) + XCTAssertTrue(app.buttons["上传第一张知识截屏"].exists) + XCTAssertTrue(app.staticTexts["正在整理知识卡"].waitForExistence(timeout: 10)) + attachScreenshot("12-staging-upload-accepted", app: app) + + app.terminate() + app.launch() + XCTAssertTrue(app.buttons["打开菜单"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.buttons["打开知识库"].exists) + XCTAssertTrue(app.buttons["上传第一张知识截屏"].exists) + + waitForStagingResult(app: app, timeout: 180) + if app.buttons["整理失败,点此重试"].exists { + app.buttons["整理失败,点此重试"].tap() + waitForStagingResult(app: app, timeout: 180) + } + + let mascot = app.buttons["哦莫 记忆伙伴"] + XCTAssertTrue(mascot.exists, "真实 staging 截图任务未生成可复习卡") + mascot.tap() + revealCurrentCard(in: app) + + let rating = app.sliders["memory-rating-slider"] + XCTAssertTrue(rating.waitForExistence(timeout: 5)) + let start = rating.coordinate(withNormalizedOffset: CGVector(dx: 0.04, dy: 0.18)) + let remembered = rating.coordinate(withNormalizedOffset: CGVector(dx: 0.96, dy: 0.18)) + start.press(forDuration: 0.1, thenDragTo: remembered) + XCTAssertTrue(rating.waitForNonExistence(timeout: 15)) + + XCTAssertTrue(app.buttons["打开知识库"].waitForExistence(timeout: 5)) + app.buttons["打开知识库"].tap() + XCTAssertTrue( + app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "卡片")).firstMatch + .waitForExistence(timeout: 10) + ) + attachScreenshot("13-staging-recall-complete-library", app: app) + } + + private func waitForStagingResult(app: XCUIApplication, timeout: TimeInterval) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if app.buttons["哦莫 记忆伙伴"].exists + || app.buttons["整理失败,点此重试"].exists { + return + } + RunLoop.current.run(until: Date().addingTimeInterval(1)) + } + } + + private func revealCurrentCard(in app: XCUIApplication) { + let scratch = app.otherElements["被遮住的承重语义"] + XCTAssertTrue(scratch.waitForExistence(timeout: 5)) + for y in [0.25, 0.75] { + let leading = scratch.coordinate(withNormalizedOffset: CGVector(dx: 0.04, dy: y)) + let trailing = scratch.coordinate(withNormalizedOffset: CGVector(dx: 0.96, dy: y)) + leading.press(forDuration: 0.05, thenDragTo: trailing) + } + } + #endif + + private func launch(arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-OmoSkipLaunch", "-OmoUseFixtures"] + arguments + app.launchEnvironment["OMO_API_BASE_URL"] = "http://127.0.0.1:5174" + app.launch() + return app + } + + private func attachScreenshot(_ name: String, app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} + +private extension XCUIElement { + func waitForNonExistence(timeout: TimeInterval) -> Bool { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } +} diff --git a/PLANS.md b/PLANS.md index 6cdb6e3..21732fb 100644 --- a/PLANS.md +++ b/PLANS.md @@ -6,7 +6,7 @@ | 计划 | 分支 | 负责人 | 状态 | 模式 | 进度 | 下一步 | |---|---|---|---|---|---:|---| -| 无 | — | — | — | — | — | — | +| [[plans/codex-omo-independent-app]] | `codex/omo-independent-app` | Codex `/root` | `in_progress` | `auto` | 98% | 补齐端到端无死路用户旅程验收并发布外部 TestFlight 构建 | ## 使用规则 diff --git a/README.md b/README.md index d1f7e11..88d574d 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,24 @@ npm --prefix backend run dev 截图处理先由 Qwen 提取当前内容的标题、作者与平台,再通过 TikHub 严格核对原来源。只有标题和作者同时匹配时才标记为“TikHub 已核验”;缺少配置、超时或上游失败都保留为带 `sourceReason` 的 `screenshot_only`,不会伪装成已核验。 -当前 JSON Store 只用于本地开发,不是耐久生产存储。因此 `NODE_ENV=production` 下 readiness 和业务接口会保持失败,直到接入通过验证的耐久存储 Adapter;本地测试通过不代表 Railway、Vercel 或真实提供方已经验证。 +当前 JSON Store 只用于本地开发,不是耐久生产存储。`NODE_ENV=production` 下缺少 `DATABASE_URL`、连接失败或 migration 未就绪时,readiness 和业务接口都会保持失败;本地测试通过不代表 Railway、Vercel 或真实提供方已经验证。 + +## PostgreSQL + +设置 `DATABASE_URL` 后,后端使用 PostgreSQL Store,且不会回退到 JSON。首次使用先显式运行顺序 migration: + +```bash +npm --prefix backend run db:migrate +npm --prefix backend run db:check +``` + +本地完整数据库门禁会创建临时 PostgreSQL 集群并使用合成数据: + +```bash +npm --prefix backend run test:postgres +``` + +服务不会在启动时自动迁移数据库。JSON 导入、备份恢复、owner 过渡合同和生产未验证边界见 [PostgreSQL 持久化合同](docs/postgres-persistence.md)。 ## 检查 diff --git a/artifacts/active-recall-reintegration/01-empty-home.png b/artifacts/active-recall-reintegration/01-empty-home.png new file mode 100644 index 0000000..0647c05 Binary files /dev/null and b/artifacts/active-recall-reintegration/01-empty-home.png differ diff --git a/artifacts/active-recall-reintegration/02-populated-home.png b/artifacts/active-recall-reintegration/02-populated-home.png new file mode 100644 index 0000000..cc44626 Binary files /dev/null and b/artifacts/active-recall-reintegration/02-populated-home.png differ diff --git a/artifacts/active-recall-reintegration/03-covered-four-layer-deck.png b/artifacts/active-recall-reintegration/03-covered-four-layer-deck.png new file mode 100644 index 0000000..7692325 Binary files /dev/null and b/artifacts/active-recall-reintegration/03-covered-four-layer-deck.png differ diff --git a/artifacts/active-recall-reintegration/04-partial-scratch.png b/artifacts/active-recall-reintegration/04-partial-scratch.png new file mode 100644 index 0000000..305924b Binary files /dev/null and b/artifacts/active-recall-reintegration/04-partial-scratch.png differ diff --git a/artifacts/active-recall-reintegration/05-revealed-rating-cancel.png b/artifacts/active-recall-reintegration/05-revealed-rating-cancel.png new file mode 100644 index 0000000..3b699a3 Binary files /dev/null and b/artifacts/active-recall-reintegration/05-revealed-rating-cancel.png differ diff --git a/artifacts/active-recall-reintegration/06-submit-failure-retry.png b/artifacts/active-recall-reintegration/06-submit-failure-retry.png new file mode 100644 index 0000000..ce4b16c Binary files /dev/null and b/artifacts/active-recall-reintegration/06-submit-failure-retry.png differ diff --git a/artifacts/active-recall-reintegration/07-library-full-cards.png b/artifacts/active-recall-reintegration/07-library-full-cards.png new file mode 100644 index 0000000..1fa6949 Binary files /dev/null and b/artifacts/active-recall-reintegration/07-library-full-cards.png differ diff --git a/artifacts/active-recall-reintegration/08-profile-preserved.png b/artifacts/active-recall-reintegration/08-profile-preserved.png new file mode 100644 index 0000000..e77c829 Binary files /dev/null and b/artifacts/active-recall-reintegration/08-profile-preserved.png differ diff --git a/artifacts/active-recall-reintegration/09-card-full-context.png b/artifacts/active-recall-reintegration/09-card-full-context.png new file mode 100644 index 0000000..1cdf891 Binary files /dev/null and b/artifacts/active-recall-reintegration/09-card-full-context.png differ diff --git a/artifacts/active-recall-reintegration/10-upload-photo-picker.png b/artifacts/active-recall-reintegration/10-upload-photo-picker.png new file mode 100644 index 0000000..919e7a0 Binary files /dev/null and b/artifacts/active-recall-reintegration/10-upload-photo-picker.png differ diff --git a/artifacts/active-recall-reintegration/README.md b/artifacts/active-recall-reintegration/README.md new file mode 100644 index 0000000..de27cf9 --- /dev/null +++ b/artifacts/active-recall-reintegration/README.md @@ -0,0 +1,46 @@ +# Active recall reintegration acceptance evidence + +Validated on 2026-08-02 against branch `codex/continue-from-latest-main`, based on team main commit `cb23265`. + +## Environment + +- Primary device: `Recallo Audit iPhone 17 Pro` (`7921F57F-018A-471E-997D-F23EACC4A1EE`) +- Runtime: iOS 26.5 +- API: explicit local Fixture mode (`OMO_DEMO_MODE=1`) with a temporary JSON store +- Secondary build target: iPhone SE (3rd generation), iOS 26.5 + +The Fixture run validates app/API integration and interaction state. It does not claim production Qwen or TikHub quality; production configuration remains fail-closed and is covered by backend tests. + +## Screenshots + +| File | Evidence | +| --- | --- | +| `01-empty-home.png` | First-use empty home keeps the Figma composition and points to upload. | +| `02-populated-home.png` | A populated home points to Omo as the draw entry instead of showing upload onboarding. | +| `03-covered-four-layer-deck.png` | Default draw presents the covered inline semantic and the approved four-layer stack while folder/upload remain visible. | +| `04-partial-scratch.png` | A partial scratch reveals only the touched semantic area and does not show self-rating. | +| `05-revealed-rating-cancel.png` | At least 80% coverage reveals the complete weighted semantic and four-position self-rating control; returning to the left cancels without changing cards. | +| `06-submit-failure-retry.png` | Failed assessment stays on the revealed card, preserves the selected slider position, and exposes an inline retry action. | +| `07-library-full-cards.png` | The Library shows complete knowledge cards directly, with no scratch interaction. | +| `08-profile-preserved.png` | The team's current Profile screen remains reachable from the side menu. | +| `09-card-full-context.png` | Card details are an optional sheet; the semantic has stronger weight/color and source status is visible. | +| `10-upload-photo-picker.png` | The persistent plus button opens the iOS single-photo picker during a recall round. | + +## Interaction results + +- Partial scratch: pass; rating stays hidden. +- 80% reveal: pass; the semantic completes and the rating appears. +- Cancel at the far-left position: pass; the current card remains. +- Submit `forgot`, `fuzzy`, and `remembered`: pass; each value persisted exactly once and advanced to a resealed card. +- Offline submit and retry: pass; the failed selection remained visible and retry succeeded after the API returned. +- Details sheet, Library, Profile, and in-round photo picker: pass. +- Reduce Motion and accessibility-large text smoke checks: pass without clipping or loss of primary controls. +- Small-screen compile: pass on iPhone SE (3rd generation), iOS 26.5. + +## Automated gates + +- Backend syntax check: pass. +- Backend tests: 31 passed, 0 failed. +- Documentation check: 18 Markdown files and 147 wiki links passed. +- iOS unit tests: 10 passed, 0 failed. +- Primary and iPhone SE Simulator builds: pass. diff --git a/artifacts/testflight-staging-release/ai-processing-consent.png b/artifacts/testflight-staging-release/ai-processing-consent.png new file mode 100644 index 0000000..38c9ca3 Binary files /dev/null and b/artifacts/testflight-staging-release/ai-processing-consent.png differ diff --git a/artifacts/testflight-staging-release/context-after-reveal.png b/artifacts/testflight-staging-release/context-after-reveal.png new file mode 100644 index 0000000..3bee2f4 Binary files /dev/null and b/artifacts/testflight-staging-release/context-after-reveal.png differ diff --git a/artifacts/testflight-staging-release/context-before-reveal.png b/artifacts/testflight-staging-release/context-before-reveal.png new file mode 100644 index 0000000..2a1af71 Binary files /dev/null and b/artifacts/testflight-staging-release/context-before-reveal.png differ diff --git a/artifacts/testflight-staging-release/notification-overlay-late.png b/artifacts/testflight-staging-release/notification-overlay-late.png new file mode 100644 index 0000000..b49552e Binary files /dev/null and b/artifacts/testflight-staging-release/notification-overlay-late.png differ diff --git a/artifacts/testflight-staging-release/notification-rating-retry.png b/artifacts/testflight-staging-release/notification-rating-retry.png new file mode 100644 index 0000000..9626a8c Binary files /dev/null and b/artifacts/testflight-staging-release/notification-rating-retry.png differ diff --git a/artifacts/testflight-staging-release/notification-revealed-rating.png b/artifacts/testflight-staging-release/notification-revealed-rating.png new file mode 100644 index 0000000..1032a0b Binary files /dev/null and b/artifacts/testflight-staging-release/notification-revealed-rating.png differ diff --git a/artifacts/testflight-staging-release/privacy-settings-revoked.png b/artifacts/testflight-staging-release/privacy-settings-revoked.png new file mode 100644 index 0000000..b833810 Binary files /dev/null and b/artifacts/testflight-staging-release/privacy-settings-revoked.png differ diff --git a/artifacts/testflight-staging-release/real-staging-card-context.png b/artifacts/testflight-staging-release/real-staging-card-context.png new file mode 100644 index 0000000..e876c43 Binary files /dev/null and b/artifacts/testflight-staging-release/real-staging-card-context.png differ diff --git a/artifacts/testflight-staging-release/real-staging-library-search.png b/artifacts/testflight-staging-release/real-staging-library-search.png new file mode 100644 index 0000000..6cd4f01 Binary files /dev/null and b/artifacts/testflight-staging-release/real-staging-library-search.png differ diff --git a/artifacts/testflight-staging-release/reduce-motion-rating-retry.png b/artifacts/testflight-staging-release/reduce-motion-rating-retry.png new file mode 100644 index 0000000..bd3be0a Binary files /dev/null and b/artifacts/testflight-staging-release/reduce-motion-rating-retry.png differ diff --git a/artifacts/testflight-staging-release/small-screen-library-axxxl.png b/artifacts/testflight-staging-release/small-screen-library-axxxl.png new file mode 100644 index 0000000..8a916ec Binary files /dev/null and b/artifacts/testflight-staging-release/small-screen-library-axxxl.png differ diff --git a/artifacts/testflight-staging-release/small-screen-library.png b/artifacts/testflight-staging-release/small-screen-library.png new file mode 100644 index 0000000..919388b Binary files /dev/null and b/artifacts/testflight-staging-release/small-screen-library.png differ diff --git a/artifacts/testflight-staging-release/speech-permission-denied.jpg b/artifacts/testflight-staging-release/speech-permission-denied.jpg new file mode 100644 index 0000000..6315cf0 Binary files /dev/null and b/artifacts/testflight-staging-release/speech-permission-denied.jpg differ diff --git a/artifacts/testflight-staging-release/task4-notification-validation.md b/artifacts/testflight-staging-release/task4-notification-validation.md new file mode 100644 index 0000000..2ff5b56 --- /dev/null +++ b/artifacts/testflight-staging-release/task4-notification-validation.md @@ -0,0 +1,29 @@ +# Task 4 本机问题通知验收 + +- 日期:2026-08-08 +- 分支:`codex/testflight-staging-release` +- 设备:Omo TestFlight iPhone 17 Pro,iOS 26.5 Simulator +- 数据:Debug 显式启动参数注入的合成卡片,不含真实用户内容 +- 远端状态:未连接 Railway,未部署,未读取生产数据 + +## 已验证 + +1. 通知计划的可见文案仅包含提问,payload 仅包含 `cardID`,不包含答案、解释或截图。 +2. 生成卡片、完成自评和删除卡片时,本机通知会分别新建、更新和取消。 +3. 模拟通知点击后,App 回到“今日”并在当前首页层级叠加对应卡片;不打开独立做题页。 +4. 底层首页、收藏夹和上传入口保留。 +5. 卡片未加载时暂存 ID,加载后再解析;无效 ID 安全丢弃。 +6. 实际拖擦遮罩时,局部刮开仅显示对应区域;连续拖擦达到 80% 阈值后完整显示语义并出现自评条。 +7. 实际拖动自评条到“记住了”可提交;本地未启动 backend 时保持当前卡片并显示“保存失败,点此重试”,没有误切下一张。 +8. XCTest:35 passed / 0 failed / 0 skipped。 +9. Release Simulator build 通过;无 APNs entitlement,无 Debug Fixture/通知注入参数、本地地址或旧生产域名字符串。 + +## 截图 + +- `notification-overlay-late.png`:通知对应的未揭示卡片叠加在首页,底层入口仍可见。 +- `notification-revealed-rating.png`:达到揭示阈值后完整显示承重语义和三档自评条。 +- `notification-rating-retry.png`:自评提交遇到本地网络失败时保留当前卡并提供重试。 + +## 后续验收 + +- 任务 6 在隔离 staging 就绪后复验自评成功进入下一张,并检查系统通知权限弹窗与真机通知点击。 diff --git a/artifacts/testflight-staging-release/task5-staging-bootstrap.md b/artifacts/testflight-staging-release/task5-staging-bootstrap.md new file mode 100644 index 0000000..f35b7c8 --- /dev/null +++ b/artifacts/testflight-staging-release/task5-staging-bootstrap.md @@ -0,0 +1,32 @@ +# Task 5 Railway staging 建立证据 + +- 日期:2026-08-08 +- 分支:`codex/testflight-staging-release` +- 目标项目:`Omo TestFlight Staging` +- 目标环境:`staging` +- 服务:`omo-api-staging`、独立 `Postgres` + +## 完成 + +1. 通过 `railway init --name "Omo TestFlight Staging"` 新建空项目。 +2. 新建并链接 `staging` 环境;Railway 自动生成的空 `production` 环境未放置任何服务。 +3. 在 `staging` 中新建独立 Postgres 和空的 `omo-api-staging`。 +4. 配置非秘密变量与同项目 Postgres 引用,设置时使用 `--skip-deploys`。 +5. 首次只读 migration status:`storage_migration_required`,applied 为空,pending 为 `001/002`。 +6. 本机不能解析 Railway 私网域名;改用临时密码保护 TCP proxy 执行 migration。 +7. Migration 结果:target `002`,newly applied `001/002`;复查 ready 为 true,pending 为空。 +8. 临时 TCP proxy 已删除,删除后 proxy 列表为空。 +9. 用户在 Railway staging 中补充 `QWEN_API` / `TIKHUB_API_KEY`;检查仅确认键存在,没有读取或输出值。 +10. Railpack 空服务的后续部署复用了不含 Node/npm 的初始化镜像;改为根目录显式 Node 20 Dockerfile,并以回归测试锁定 builder、启动命令和 `0.0.0.0` 监听。 +11. 部署 `c35b57f1-90da-4d80-b78a-0eb9145f56d0` 成功,平台 `/api/readiness` 健康检查通过。 +12. 公网域名为 `https://omo-api-staging-staging.up.railway.app`;health 与 readiness 均为 200,Qwen、TikHub、PostgreSQL ready,migration applied 为 `001/002`,pending 为空。 +13. 使用合成截图和全新匿名设备 ID 验证:空库、真实生成、读取、语义搜索、assessment 幂等、容器重启后回读与删除。来源因无可验证原文按设计为 `screenshot_only`;最终测试库恢复为空。 +14. Release 构建固定连接上述 staging HTTPS 域名;Simulator Release 包内配置、隐私清单、加密声明与 forbidden-string 扫描通过,完整 XCTest 37/37 通过。 + +## 安全结论 + +- 未 link、读取或修改现有项目“拾贝”。 +- 未读取或导入生产数据。 +- 未部署或修改生产;只部署了新项目的 `staging` backend。 +- 未输出、记录或提交数据库凭据。 +- 所有代码更改仅位于 `codex/testflight-staging-release`。 diff --git a/artifacts/testflight-staging-release/task6-local-release-audit.md b/artifacts/testflight-staging-release/task6-local-release-audit.md new file mode 100644 index 0000000..0692d79 --- /dev/null +++ b/artifacts/testflight-staging-release/task6-local-release-audit.md @@ -0,0 +1,54 @@ +# Task 6 本地发布与隐私流程验证 + +- 日期:2026-08-08 +- 分支:`codex/testflight-staging-release` +- Simulator:`Omo TestFlight iPhone 17 Pro`(iOS 26.5) +- 外部环境:未部署 backend,未触碰生产环境 + +## 自动化结果 + +- iOS XCTest:37/37 通过,确认测试方法真实执行。 +- Backend:46 项,45 通过,1 项 PostgreSQL 集成测试在默认无数据库环境下跳过。 +- Backend syntax check:通过。 +- 文档检查:25 个 Markdown、185 个 wiki link,全部通过。 +- 未签名 Release device build:通过,并执行 App Store shallow validation。 +- Release 包检查:包含更新后的 `PrivacyInfo.xcprivacy`;不包含 localhost、旧生产域名或 Debug Fixture/通知注入字符串。 +- Export compliance:App 仅使用系统 HTTPS,Info.plist 声明不使用非豁免加密。 + +## Simulator 实际交互 + +1. 清除 App 测试安装并从空用户首页启动。 +2. 点击首页上传入口,进入系统照片选择器并选择一张照片。 +3. 确认截图离开设备前显示“允许 AI 处理这张截图?”;取消后不授权、不生成。 +4. 再次选图并点“同意并生成”,确认许可持久化;测试 backend 未部署时只显示连接失败,不伪造成功。 +5. 打开 Settings,确认存在隐私说明、联系支持和撤回许可入口。 +6. 点击撤回后,入口立即变为“下次上传截图时会询问 AI 处理许可”;再次选图会重新提示。 +7. 在 `Omo Verify iPhone SE 3` 复核知识库:默认字号保持两列;最大辅助字号下自动切为单列宽卡,长文本不再逐字竖排或被截断。 +8. 在 iPhone 17 Pro 使用 Debug 合成数据验证知识库文字搜索、语音权限拒绝提示及跳转 Settings 的恢复入口。 +9. 使用运行时 Accessibility 快照核对回顾卡:刮开前既不包含承重语义,也不提供“查看完整知识上下文”;达到 80% 后才同时出现完整语义、自评条与上下文入口。 +10. 在系统“减弱动态效果”开启时启动回顾:卡片无需等待动画即可进入揭示态,自评拖拽仍会提交;本地 API 不可用时正常停留在当前卡并显示重试,不误切下一张。验证后已把测试 Simulator 设置恢复为关闭。 + +## 本轮发现并修复 + +- 初版许可提示只覆盖知识库次级上传页,首页主上传入口仍直接生成。通过真实系统照片选择器复现后,将相同许可门槛补到首页主入口。 +- Debug localhost 判断仍残留在 Release 二进制。将整个本地 HTTP 分支收进 `#if DEBUG`,clean Release 重建后字符串门禁通过。 +- 隐私文档包含尚未实现的配额、反馈入口和诊断字段。已按当前代码实际行为收敛,并补齐 App 内可访问隐私说明。 +- 小屏最大辅助字号下,固定双列卡片会把正文压缩为逐字竖排。分页器新增显式列数,页面仅在辅助字号下切为单列;默认字号的两列 Figma 布局保持不变。 +- 回顾卡在刮开前已显示“查看完整知识上下文”,可绕过遮挡直接看到答案。入口现由回顾状态控制,仅在 80% 揭示后出现;知识库中的完整卡片查看保持不变。 + +## 证据 + +- `ai-processing-consent.png`:首页真实选图后的首次 AI 处理许可。 +- `privacy-settings-revoked.png`:Settings 中撤回后的状态与隐私入口。 +- `small-screen-library.png`:小屏默认字号的两列知识库布局。 +- `small-screen-library-axxxl.png`:小屏最大辅助字号的单列可读布局。 +- `speech-permission-denied.jpg`:语音权限拒绝后的可恢复提示。 +- `context-before-reveal.png`:刮开前无完整上下文入口。 +- `context-after-reveal.png`:达到揭示阈值后显示上下文入口与自评条。 +- `reduce-motion-rating-retry.png`:Reduce Motion 下自评与失败重试仍可达。 + +## 尚未验证 + +- 真实截图生成、持久化、搜索、assessment、删除和重启 readback:等待隔离 staging 的 Qwen/TikHub 专用密钥后执行。 +- 小屏键盘、真实系统语音授权、VoiceOver 实机朗读与完整 TestFlight 安装:Task 6/7 后续。 +- App Store Connect:已安装 `asc 3.5.1`,本机无 ASC API 凭据;仅有 Apple Development 证书、无 Distribution profile,未访问或修改远端记录。 diff --git a/artifacts/testflight-staging-release/task6-real-staging-validation.md b/artifacts/testflight-staging-release/task6-real-staging-validation.md new file mode 100644 index 0000000..4b50d69 --- /dev/null +++ b/artifacts/testflight-staging-release/task6-real-staging-validation.md @@ -0,0 +1,38 @@ +# Task 6 Release Simulator × Real Staging 验收 + +> 历史说明:本次 Simulator 验证发生在产品身份纠正之前,App bundle 当时仍为旧 `com.maxhan.shibei`。它只证明 UI/API/staging 闭环,不构成 Omo 独立 App 的发布证据。后续发布必须使用 `com.maxhan.omo`。 + +- 日期:2026-08-08 +- 分支:`codex/testflight-staging-release` +- Simulator:`Omo TestFlight iPhone 17 Pro`(iOS 26.5) +- App:Release,`com.maxhan.shibei` +- API:`https://omo-api-staging-staging.up.railway.app` +- 数据:仅使用仓库内合成界面截图;未使用真实用户截图或生产数据 + +## 实际通过路径 + +1. 清空 Omo App 数据后首次启动显示空库,只提供菜单和“上传第一张知识截屏”。 +2. 系统 PhotosPicker 能看到导入的合成截图;选择截图后、发送给 AI 前出现独立许可说明。 +3. 点击“同意并生成”后显示整理状态,真实 staging 生成成功,并弹出系统通知权限请求。 +4. 允许通知后回到有卡首页;IP、知识库和上传入口同时存在。 +5. 点击 IP 进入一张卡回顾;揭示前承重语义被遮挡,知识库完整上下文入口不可绕过遮挡。 +6. 实际刮擦达到阈值后显示差异化文字权重和三档自评条。 +7. 先拖到自评节点再拖回最左端松手,卡片不切换;随后提交“记住了”,一张卡轮次结束并回到首页。 +8. staging PostgreSQL 读取确认 `reviewCount=1`、`lastAssessment=remembered`,证明 iOS 提交真实落库。 +9. 知识库直接展示完整卡片;英文语义查询 `memory screenshot` 经真实 staging 搜索后保留相关卡片。 +10. 卡片详情显示完整知识、解释与来源;完成后回到原搜索状态。 +11. 通过 owner 隔离的删除 API 清除合成卡;App 重启后重新显示空库,staging 中该测试用户卡片数为 0。 + +## 证据 + +- `real-staging-library-search.png`:真实 staging 搜索结果与完整卡片。 +- `real-staging-card-context.png`:真实生成卡的完整知识详情。 +- backend 公网闭环与 deployment 记录见 `task5-staging-bootstrap.md`。 +- 全量 iOS XCTest:37 passed / 0 failed / 0 skipped。 + +## 边界与未宣称事项 + +- 中文输入自动化工具只支持美式键盘字符,因此本轮真实搜索使用英文;中文请求编码由 XCTest 合同覆盖。 +- 当前 Figma/MVP 没有用户可见删除入口;只验证既有后端删除合同,不在本轮临时新增未设计的交互。 +- 通知权限和本机调度已验证;通知点击叠卡证据沿用 `task4-notification-validation.md`,未等待真实复习间隔。 +- 未访问、修改或部署 Railway 生产项目“拾贝”,未触碰 `main`。 diff --git a/artifacts/testflight-staging-release/task7-offline-review-audit.md b/artifacts/testflight-staging-release/task7-offline-review-audit.md new file mode 100644 index 0000000..9e9f11f --- /dev/null +++ b/artifacts/testflight-staging-release/task7-offline-review-audit.md @@ -0,0 +1,48 @@ +# Task 7 旧 App 错误发布处置记录 + +- 日期:2026-08-08 +- 错误构建:旧 App Store Connect App ID `6772533617` 下的 `1.0 (28)` +- 错误 Build ID:`029508e7-5041-47e0-a6ba-1bd4bb3e6ea5` +- 根因:错误把全新产品 Omo 当成旧 Recaro/Recallo 的更新,复用了旧 Bundle ID `com.maxhan.shibei`、旧 App 记录和旧内部测试组。 + +## 已完成止损 + +1. Build 28 已执行移组并永久过期。由于旧内部组配置为 `hasAccessToAllBuilds=true`,关系 API 仍列出过期 build 28,但其 internal/external state 均为 `EXPIRED`,不可安装。 +2. Build 28 已永久设为 `EXPIRED`;`internalBuildState` 与 `externalBuildState` 均为 `EXPIRED`。 +3. 错误 Draft PR #38 已关闭并注明原因,不能合并。 +4. 旧 App 的 build 1–27、历史用户、测试员、生产数据和生产环境均未修改。 +5. 新产品 Bundle ID `com.maxhan.omo` 已注册,资源 ID 为 `B696RZHHNR`;注册前确认团队中不存在该 Bundle ID,也不存在对应 App Store Connect App。 +6. Recallo App 名称仍为 `Recallo`,副标题仍为 `不记笔记`;商店描述、关键词与宣传文字保持旧产品内容。 +7. App Privacy URL 已恢复为 `https://shibei-production.up.railway.app/privacy`,Support URL 已恢复为 `https://shibei-production.up.railway.app/support`。 +8. TestFlight 中文说明已恢复为仓库中保存的 Recallo Beta 测试流程,反馈邮箱保持原值。 +9. 旧 build 27 为 `VALID / IN_BETA_TESTING`,自动通知开启;原内部组仍有 10 位测试员,原外部组未删除。 +10. 两份本次误建的旧 Bundle App Store profile(`XKVZHAZMZ5`、`4NPHQ877RA`)已删除;既有旧签名资源未改动。 + +## 新 Omo 发布边界 + +- 主 App target 必须使用 `com.maxhan.omo`;Tests 使用 `com.maxhan.omo.Tests`。 +- Omo 必须创建独立 App Store Connect App ID、独立 build number 序列和独立 TestFlight 测试组。 +- 新测试组不得自动继承旧 App 测试员;添加测试员前需用户确认范围。 +- 安装后的显示名保持 `Omo`。精确商店名称 `Omo` 已被占用,新 App 记录名称必须由用户确认,不能再次借用旧 App。 +- Railway `Omo TestFlight Staging` 本身为全新隔离项目,可继续作为 Omo 内测后端;禁止触碰生产项目“拾贝”。 + +## 不再成立的结论 + +此前关于 build 28 “可供内部测试员安装”的结论已经撤销。该 IPA 的包审计只能作为代码、签名和 staging 配置的历史技术证据,不能作为 Omo 独立 TestFlight 发布证据。 + +新的发布审计必须以 `com.maxhan.omo`、新 App ID 和新 Build ID 为准。 + +## 独立 Omo 本地发布准备 + +- 工程主 target 已切换为 `com.maxhan.omo`,版本为 `1.0 (1)`,最低系统为 iOS 17.0。 +- 独立 provisioning profile 已创建;ASC profile ID 为 `RJCF68G8U5`,绑定 `44589Y6FA6.com.maxhan.omo`,`get-task-allow=false`。 +- 签名 Archive `.release/Omo-Independent-1.0-1.xcarchive` 已成功,Store validation 通过。 +- 本地 IPA `.release/export-independent-1.0-1/Omo.ipa` 已成功导出,并上传到独立 Omo App。 +- IPA SHA-256 为 `6f010cfb9ee1c5f27fad60eb89050307256b44882d9f2b614aa83f0d85ea7b10`;Bundle ID、版本、iOS 17.0、staging URL、PrivacyInfo、签名 entitlement 与 forbidden-string 审计通过。 +- Backend 51 项中 50 pass、1 项默认 PostgreSQL skip;独立身份门禁通过。 +- 当前独立 Bundle `com.maxhan.omo` 已实际执行全部 Simulator XCTest:37 通过、0 失败、0 跳过;测试日志与 xcresult 均保存在本机 XcodeBuildMCP workspace 中。 +- 独立 App Store Connect App 已创建:名称 `Omo(哦莫)`,App ID `6799407458`,Bundle ID `com.maxhan.omo`,SKU `omo-ios-001`,主语言 `zh-Hans`。 +- 独立 build `1.0 (1)` 已上传,Build ID `7bb306b6-dc29-43ca-9547-782ae6fa2009`;Apple 状态为 `VALID`,最低 iOS 17.0,`usesNonExemptEncryption=false`,未过期。 +- Build 1 的 App 关系 API 只返回 Omo App ID `6799407458`,不属于 Recallo App ID `6772533617`。 +- Omo 独立内部组 ID 为 `109f9f4f-75b9-4419-be1f-46edd4bc016a`,当前只关联 build 1,测试员数量为 0;没有继承 Recallo 的 10 位内部测试员。 +- Omo TestFlight 中文说明与独立 staging 隐私 URL 已配置;Recallo 的 TestFlight 说明和 production URL 保持恢复后的旧内容。 diff --git a/backend/.env.example b/backend/.env.example index f5ff9a4..82302f3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,6 +5,7 @@ PORT=5174 # Fixture 只允许本地/测试显式开启;生产环境即使设为 1 也会 fail closed。 OMO_DEMO_MODE=0 +# 同一组 staging Qwen 配置同时用于截图生成与知识库语义重排。 QWEN_API= QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 QWEN_MODEL=qwen3-vl-plus @@ -16,3 +17,10 @@ TIKHUB_TIMEOUT_MS=15000 # 当前 JSON Store 只用于本地开发,不满足生产 readiness。 CARD_STORE_PATH=.runtime/cards.json + +# PostgreSQL 只在显式提供 DATABASE_URL 时启用;值留空且不得提交真实凭据。 +STORE_DRIVER=json +DATABASE_URL= +DATABASE_POOL_MAX=10 +DATABASE_CONNECT_TIMEOUT_MS=5000 +DATABASE_IDLE_TIMEOUT_MS=30000 diff --git a/backend/migrations/001-create-owners-and-memory-cards.sql b/backend/migrations/001-create-owners-and-memory-cards.sql new file mode 100644 index 0000000..537c37e --- /dev/null +++ b/backend/migrations/001-create-owners-and-memory-cards.sql @@ -0,0 +1,25 @@ +CREATE TABLE omo_owners ( + owner_id TEXT PRIMARY KEY, + owner_kind TEXT NOT NULL DEFAULT 'device' + CHECK (owner_kind IN ('device', 'account')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (char_length(owner_id) BETWEEN 1 AND 200) +); + +CREATE TABLE omo_memory_cards ( + owner_id TEXT NOT NULL REFERENCES omo_owners(owner_id) ON DELETE CASCADE, + card_id TEXT NOT NULL, + card JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + next_review_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (owner_id, card_id), + CHECK (char_length(card_id) BETWEEN 1 AND 200), + CHECK (jsonb_typeof(card) = 'object') +); + +CREATE INDEX omo_memory_cards_owner_created_idx + ON omo_memory_cards (owner_id, created_at DESC, card_id); + +CREATE INDEX omo_memory_cards_owner_review_idx + ON omo_memory_cards (owner_id, next_review_at, card_id); diff --git a/backend/migrations/002-add-assessment-idempotency-and-version.sql b/backend/migrations/002-add-assessment-idempotency-and-version.sql new file mode 100644 index 0000000..9dece95 --- /dev/null +++ b/backend/migrations/002-add-assessment-idempotency-and-version.sql @@ -0,0 +1,17 @@ +ALTER TABLE omo_memory_cards + ADD COLUMN version BIGINT NOT NULL DEFAULT 1 + CHECK (version > 0); + +CREATE TABLE omo_assessment_attempts ( + owner_id TEXT NOT NULL, + card_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + assessment TEXT NOT NULL + CHECK (assessment IN ('remembered', 'fuzzy', 'forgot')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (owner_id, card_id, attempt_id), + FOREIGN KEY (owner_id, card_id) + REFERENCES omo_memory_cards(owner_id, card_id) + ON DELETE CASCADE, + CHECK (char_length(attempt_id) BETWEEN 1 AND 200) +); diff --git a/backend/migrations/003-create-screenshot-jobs.sql b/backend/migrations/003-create-screenshot-jobs.sql new file mode 100644 index 0000000..7364aef --- /dev/null +++ b/backend/migrations/003-create-screenshot-jobs.sql @@ -0,0 +1,37 @@ +CREATE TABLE omo_screenshot_jobs ( + owner_id TEXT NOT NULL REFERENCES omo_owners(owner_id) ON DELETE CASCADE, + job_id TEXT NOT NULL, + fingerprint TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'accepted' + CHECK (state IN ('accepted', 'processing', 'succeeded', 'failed')), + image_base64 TEXT, + mime_type TEXT NOT NULL DEFAULT 'image/jpeg', + attempt_count INTEGER NOT NULL DEFAULT 0 + CHECK (attempt_count >= 0), + attempt_token TEXT, + lease_expires_at TIMESTAMPTZ, + card_id TEXT, + error_code TEXT NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '', + retryable BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (owner_id, job_id), + UNIQUE (owner_id, fingerprint), + CHECK (char_length(job_id) BETWEEN 1 AND 200), + CHECK (char_length(fingerprint) = 64), + CHECK (char_length(mime_type) BETWEEN 1 AND 100), + CHECK (attempt_token IS NULL OR char_length(attempt_token) BETWEEN 1 AND 200), + CHECK (card_id IS NULL OR char_length(card_id) BETWEEN 1 AND 200), + CHECK ( + (state IN ('accepted', 'processing') AND image_base64 IS NOT NULL) + OR (state IN ('succeeded', 'failed') AND image_base64 IS NULL) + ), + CHECK ( + (state = 'processing' AND attempt_token IS NOT NULL AND lease_expires_at IS NOT NULL) + OR (state <> 'processing' AND attempt_token IS NULL AND lease_expires_at IS NULL) + ) +); + +CREATE INDEX omo_screenshot_jobs_owner_state_idx + ON omo_screenshot_jobs (owner_id, state, created_at, job_id); diff --git a/backend/package-lock.json b/backend/package-lock.json index 04b4b9c..e84823d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,9 +7,158 @@ "": { "name": "omo-api", "version": "1.0.0", + "dependencies": { + "pg": "8.22.0" + }, "engines": { "node": ">=20" } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/backend/package.json b/backend/package.json index 448516b..610eb9f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,12 +6,19 @@ "scripts": { "dev": "node --env-file-if-exists=../.env src/server.js", "start": "node src/server.js", - "check": "node --check src/server.js && node --check src/runtimeConfig.js && node --check src/cardService.js && node --check src/sourceVerifier.js && node --check src/store.js", + "check": "node --check src/server.js && node --check src/runtimeConfig.js && node --check src/cardService.js && node --check src/searchService.js && node --check src/sourceVerifier.js && node --check src/store.js && node --check src/storeFactory.js && node --check src/postgresStore.js && node --check src/migrations.js && node --check src/migrate.js && node --check src/importJsonStore.js && node --check tools/runPostgresTests.mjs", "docs:check": "node ../tools/check-wikilinks.mjs", + "db:migrate": "node src/migrate.js", + "db:check": "node src/migrate.js --check", + "db:import-json": "node src/importJsonStore.js", "test": "node --test", - "test:all": "node --test" + "test:all": "node --test", + "test:postgres": "node tools/runPostgresTests.mjs" }, "engines": { "node": ">=20" + }, + "dependencies": { + "pg": "8.22.0" } } diff --git a/backend/src/cardService.js b/backend/src/cardService.js index 6eccfc8..8a98e0f 100644 --- a/backend/src/cardService.js +++ b/backend/src/cardService.js @@ -26,13 +26,31 @@ export async function createMemoryCard( const now = new Date().toISOString(); const id = `card-${createHash("sha256").update(imageBase64).digest("hex").slice(0, 20)}`; const generationMode = config.qwen.configured ? "qwen" : "fixture"; - const generated = config.qwen.configured - ? await callQwen(imageBase64, mimeType, { config, fetchImpl }) - : config.demo.enabled - ? demoCard() - : failModelConfiguration(); + let generated; + if (config.qwen.configured) { + generated = await callQwen(imageBase64, mimeType, { + config, + fetchImpl, + mode: "generate" + }); + const validationError = generatedCardError(generated); + if (validationError) { + generated = await callQwen(imageBase64, mimeType, { + config, + fetchImpl, + mode: "repair", + invalidCandidate: generated, + validationError + }); + } + } else { + generated = config.demo.enabled ? demoCard() : failModelConfiguration(); + } validateGeneratedCard(generated); + const coreKnowledge = text(generated.coreKnowledge); + const hiddenSemantic = text(generated.hiddenSemantic); + const source = await verifySourceImpl(generated, { apiKey: config.tikhub.apiKey, baseURL: config.tikhub.baseURL, @@ -48,9 +66,10 @@ export async function createMemoryCard( return { id, generationMode, - coreKnowledge: text(generated.coreKnowledge), + coreKnowledge, + hiddenSemantic, recallCue: text(generated.recallCue), - answer: text(generated.answer), + answer: hiddenSemantic, explanation: text(generated.explanation), sourceTitle: text( source.title || generated.sourceTitle, @@ -63,7 +82,9 @@ export async function createMemoryCard( sourceProvider: source.provider, sourceReason: text(source.reason), sourceConfidence: Number(source.confidence || 0), - rarity: validRarities.has(generated.rarity) ? generated.rarity : "R", + rarity: source.status === "verified" && validRarities.has(generated.rarity) + ? generated.rarity + : "R", createdAt: now, masteryStage: "sealed", nextReviewAt: now, @@ -75,11 +96,22 @@ export async function createMemoryCard( }; } -async function callQwen(imageBase64, mimeType, { config, fetchImpl }) { +async function callQwen( + imageBase64, + mimeType, + { + config, + fetchImpl, + mode, + invalidCandidate, + validationError + } +) { if (!config.qwen.baseURLValid || !config.qwen.timeoutValid || !config.qwen.model) { throw httpError(503, "model_config_invalid", "视觉模型配置无效。"); } + const isRepair = mode === "repair"; let response; try { response = await fetchImpl(`${config.qwen.baseURL}/chat/completions`, { @@ -97,7 +129,8 @@ async function callQwen(imageBase64, mimeType, { config, fetchImpl }) { role: "system", content: [ "你是 Omo 的记忆卡编辑器。只依据截图可见内容,提炼一个最值得长期记住的知识点。", - "输出 JSON:coreKnowledge、recallCue、answer、explanation、sourceTitle、sourceAccount、platform、rarity。", + "输出 JSON:coreKnowledge、hiddenSemantic、recallCue、explanation、sourceTitle、sourceAccount、platform、rarity。", + "hiddenSemantic 必须非空,并且必须是 coreKnowledge 中字符完全一致的连续子串;它应是删去后能形成真实回忆缺口的承重语义。", "sourceTitle 必须是当前主内容标题,sourceAccount 必须是当前发布者或 UP 主;忽略推荐列表、广告、画面字幕、合集名和状态栏。", "platform 只能是 bilibili、douyin、xiaohongshu、wechat、zhihu、youtube、unknown。", "rarity 只能是 R、SR、SSR;信息不足时保持谨慎,不补充截图外事实。" @@ -106,7 +139,17 @@ async function callQwen(imageBase64, mimeType, { config, fetchImpl }) { { role: "user", content: [ - { type: "text", text: "请把这张截图制作成一张简洁的中文记忆卡。" }, + { + type: "text", + text: isRepair + ? [ + "上一次结果违反记忆卡合同,请只依据同一张截图重新生成完整 JSON。", + `校验错误:${validationError}`, + `上一次结果:${JSON.stringify(repairContext(invalidCandidate))}`, + "必须确保 hiddenSemantic 是 coreKnowledge 中逐字一致的连续子串。" + ].join("\n") + : "请把这张截图制作成一张简洁的中文记忆卡。" + }, { type: "image_url", image_url: { url: `data:${mimeType};base64,${imageBase64}` } } ] } @@ -153,8 +196,8 @@ async function callQwen(imageBase64, mimeType, { config, fetchImpl }) { function demoCard() { return { coreKnowledge: "截图只有在被再次想起时,才真正从收藏变成记忆。", + hiddenSemantic: "再次想起", recallCue: "保存一张截图之后,怎样才能让它不再积灰?", - answer: "把截图转成可召回的卡片,并在合适的时间主动回忆。", explanation: "这是通过 OMO_DEMO_MODE 显式开启的本地 Fixture,不代表真实模型结果。", sourceTitle: "本地 Fixture 卡", sourceAccount: "", @@ -168,10 +211,41 @@ function failModelConfiguration() { } function validateGeneratedCard(generated) { - const required = ["coreKnowledge", "recallCue", "answer", "explanation"]; + const error = generatedCardError(generated); + if (error) { + throw httpError(502, "model_invalid_response", "视觉模型返回的承重语义无法验证。"); + } +} + +export function hasValidHiddenSemantic(value) { + const coreKnowledge = text(value?.coreKnowledge); + const hiddenSemantic = text(value?.hiddenSemantic); + return hiddenSemantic.length > 0 && coreKnowledge.includes(hiddenSemantic); +} + +function generatedCardError(generated) { + const required = ["coreKnowledge", "hiddenSemantic", "recallCue", "explanation"]; if (!generated || required.some((field) => !text(generated[field]))) { - throw httpError(502, "model_invalid_response", "视觉模型返回的记忆卡字段不完整。"); + return "coreKnowledge、hiddenSemantic、recallCue 和 explanation 均不能为空。"; } + if (!hasValidHiddenSemantic(generated)) { + return "hiddenSemantic 必须是 coreKnowledge 中逐字一致的连续子串。"; + } + return ""; +} + +function repairContext(candidate) { + const fields = [ + "coreKnowledge", + "hiddenSemantic", + "recallCue", + "explanation", + "sourceTitle", + "sourceAccount", + "platform", + "rarity" + ]; + return Object.fromEntries(fields.map((field) => [field, text(candidate?.[field])])); } function text(value, fallback = "") { diff --git a/backend/src/importJsonStore.js b/backend/src/importJsonStore.js new file mode 100644 index 0000000..94a8182 --- /dev/null +++ b/backend/src/importJsonStore.js @@ -0,0 +1,97 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { getMigrationStatus } from "./migrations.js"; +import { createPostgresPool, PostgresCardStore } from "./postgresStore.js"; +import { databaseConfigValid, readRuntimeConfig } from "./runtimeConfig.js"; + +export async function importJsonCards({ filePath, store, dryRun = false }) { + if (!filePath) throw importError("import_file_required", "缺少 JSON Store 路径。"); + let entries; + try { + entries = JSON.parse(await readFile(filePath, "utf8")); + } catch { + throw importError("import_file_invalid", "JSON Store 无法读取或解析。"); + } + if (!Array.isArray(entries)) { + throw importError("import_schema_invalid", "JSON Store 顶层必须是数组。"); + } + + let imported = 0; + let existing = 0; + for (const entry of entries) { + const owner = String(entry?.owner || ""); + const card = entry?.card; + if (!owner || !card?.id || typeof card !== "object") { + throw importError("import_schema_invalid", "JSON Store 条目缺少 owner 或 card。"); + } + const current = await store.get(owner, card.id); + if (current) { + existing += 1; + continue; + } + if (!dryRun) await store.save(owner, card); + imported += 1; + } + + return { + scanned: entries.length, + imported, + existing, + dryRun + }; +} + +const isCLI = process.argv[1] + && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); + +if (isCLI) { + const config = readRuntimeConfig(); + const filePath = argumentValue("--file") || config.storage.filePath; + const dryRun = process.argv.includes("--dry-run"); + const confirmed = process.argv.includes("--confirm-authorized-data"); + + if (!config.database.configured || !databaseConfigValid(config.database)) { + console.error(JSON.stringify({ + ok: false, + code: config.database.configured ? "database_config_invalid" : "database_url_missing" + })); + process.exitCode = 1; + } else if (!dryRun && !confirmed) { + console.error(JSON.stringify({ ok: false, code: "import_authorization_required" })); + process.exitCode = 1; + } else { + const pool = createPostgresPool(config.database); + const store = new PostgresCardStore(pool); + try { + const status = await getMigrationStatus(pool); + if (!status.ready) { + throw importError(status.reason, "PostgreSQL migration 尚未就绪。"); + } + const result = await importJsonCards({ filePath, store, dryRun }); + console.log(JSON.stringify({ ok: true, ...result })); + } catch (error) { + console.error(JSON.stringify({ + ok: false, + code: error?.code || "import_failed" + })); + process.exitCode = 1; + } finally { + await store.close(); + } + } +} + +function argumentValue(name) { + const prefix = `${name}=`; + return process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) || ""; +} + +function importError(code, message) { + return Object.assign(new Error(message), { + statusCode: 422, + code, + expose: true + }); +} diff --git a/backend/src/migrate.js b/backend/src/migrate.js new file mode 100644 index 0000000..05be912 --- /dev/null +++ b/backend/src/migrate.js @@ -0,0 +1,38 @@ +import { createPostgresPool } from "./postgresStore.js"; +import { getMigrationStatus, runMigrations } from "./migrations.js"; +import { databaseConfigValid, readRuntimeConfig } from "./runtimeConfig.js"; + +const config = readRuntimeConfig(); +const checkOnly = process.argv.includes("--check"); + +if (!config.database.configured || !databaseConfigValid(config.database)) { + console.error(JSON.stringify({ + ok: false, + code: config.database.configured ? "database_config_invalid" : "database_url_missing" + })); + process.exitCode = 1; +} else { + const pool = createPostgresPool(config.database); + try { + if (checkOnly) { + const status = await getMigrationStatus(pool); + console.log(JSON.stringify(status)); + if (!status.ready) process.exitCode = 1; + } else { + const result = await runMigrations(pool); + console.log(JSON.stringify({ + ok: true, + targetVersion: result.targetVersion, + newlyApplied: result.newlyApplied + })); + } + } catch (error) { + console.error(JSON.stringify({ + ok: false, + code: error?.migrationCode || "migration_failed" + })); + process.exitCode = 1; + } finally { + await pool.end(); + } +} diff --git a/backend/src/migrations.js b/backend/src/migrations.js new file mode 100644 index 0000000..5be4a47 --- /dev/null +++ b/backend/src/migrations.js @@ -0,0 +1,211 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const MIGRATION_PATTERN = /^(\d{3})-([a-z0-9-]+)\.sql$/; +const MIGRATION_LOCK_ID = 1_704_202_629; +const defaultMigrationsDirectory = resolve( + dirname(fileURLToPath(import.meta.url)), + "../migrations" +); + +export async function loadMigrations(migrationsDirectory = defaultMigrationsDirectory) { + const fileNames = (await readdir(migrationsDirectory)) + .filter((name) => name.endsWith(".sql")) + .sort(); + const migrations = []; + const versions = new Set(); + + for (const fileName of fileNames) { + const match = fileName.match(MIGRATION_PATTERN); + if (!match) { + throw migrationError( + "migration_name_invalid", + `Migration name must match NNN-description.sql: ${fileName}` + ); + } + const version = match[1]; + if (versions.has(version)) { + throw migrationError("migration_version_duplicate", `Duplicate migration: ${version}`); + } + versions.add(version); + const sql = await readFile(resolve(migrationsDirectory, fileName), "utf8"); + migrations.push({ + version, + name: fileName, + sql, + checksum: createHash("sha256").update(sql).digest("hex") + }); + } + + if (!migrations.length) { + throw migrationError("migration_set_empty", "No migrations were found."); + } + return migrations; +} + +export async function runMigrations(pool, { + migrationsDirectory = defaultMigrationsDirectory, + targetVersion +} = {}) { + const migrations = await loadMigrations(migrationsDirectory); + const target = resolveTargetVersion(migrations, targetVersion); + const selected = migrations.filter((migration) => migration.version <= target); + const client = await connect(pool); + + try { + await client.query("BEGIN"); + await client.query("SELECT pg_advisory_xact_lock($1)", [MIGRATION_LOCK_ID]); + await ensureMigrationTable(client); + const applied = await readAppliedMigrations(client); + validateAppliedMigrations(applied, selected, migrations); + const appliedVersions = new Set(applied.map((migration) => migration.version)); + const newlyApplied = []; + + for (const migration of selected) { + if (appliedVersions.has(migration.version)) continue; + await client.query(migration.sql); + await client.query( + `INSERT INTO omo_schema_migrations (version, name, checksum) + VALUES ($1, $2, $3)`, + [migration.version, migration.name, migration.checksum] + ); + newlyApplied.push(migration.version); + } + + await client.query("COMMIT"); + return { + targetVersion: target, + appliedVersions: selected.map((migration) => migration.version), + newlyApplied + }; + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + if (error?.migrationCode) throw error; + throw migrationError("migration_failed", "Database migration failed."); + } finally { + client.release(); + } +} + +export async function getMigrationStatus(pool, { + migrationsDirectory = defaultMigrationsDirectory +} = {}) { + const migrations = await loadMigrations(migrationsDirectory); + + try { + await pool.query("SELECT 1"); + const exists = await pool.query( + `SELECT to_regclass('public.omo_schema_migrations') AS migration_table` + ); + if (!exists.rows[0]?.migration_table) { + return migrationStatus(false, "storage_migration_required", [], migrations); + } + const applied = await readAppliedMigrations(pool); + try { + validateAppliedMigrations(applied, migrations, migrations); + } catch (error) { + return migrationStatus( + false, + error.migrationCode === "migration_checksum_mismatch" + ? "storage_migration_drift" + : "storage_migration_unknown", + applied, + migrations + ); + } + const appliedVersions = new Set(applied.map((migration) => migration.version)); + const pending = migrations.filter((migration) => !appliedVersions.has(migration.version)); + if (pending.length) { + return migrationStatus(false, "storage_migration_required", applied, pending); + } + return migrationStatus(true, "", applied, []); + } catch { + return migrationStatus(false, "storage_unavailable", [], migrations); + } +} + +async function ensureMigrationTable(client) { + await client.query(` + CREATE TABLE IF NOT EXISTS omo_schema_migrations ( + version TEXT PRIMARY KEY, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); +} + +async function readAppliedMigrations(queryable) { + const result = await queryable.query( + `SELECT version, name, checksum, applied_at + FROM omo_schema_migrations + ORDER BY version` + ); + return result.rows; +} + +function validateAppliedMigrations(applied, selected, allMigrations) { + const known = new Map(allMigrations.map((migration) => [migration.version, migration])); + const selectedVersions = new Set(selected.map((migration) => migration.version)); + + for (const migration of applied) { + const expected = known.get(migration.version); + if (!expected) { + throw migrationError( + "migration_unknown_version", + `Database contains unknown migration ${migration.version}.` + ); + } + if (expected.checksum !== migration.checksum || expected.name !== migration.name) { + throw migrationError( + "migration_checksum_mismatch", + `Migration ${migration.version} does not match its applied checksum.` + ); + } + if (!selectedVersions.has(migration.version)) { + throw migrationError( + "migration_target_behind", + `Target is behind applied migration ${migration.version}.` + ); + } + } +} + +function resolveTargetVersion(migrations, targetVersion) { + if (!targetVersion) return migrations.at(-1).version; + const normalized = String(targetVersion); + if (!migrations.some((migration) => migration.version === normalized)) { + throw migrationError("migration_target_invalid", `Unknown target: ${normalized}`); + } + return normalized; +} + +async function connect(pool) { + try { + return await pool.connect(); + } catch { + throw migrationError("storage_unavailable", "Database is unavailable."); + } +} + +function migrationStatus(ready, reason, applied, pending) { + return { + ready, + driver: "postgres", + durable: true, + reason, + appliedVersions: applied.map((migration) => migration.version), + pendingVersions: pending.map((migration) => migration.version) + }; +} + +function migrationError(migrationCode, message) { + return Object.assign(new Error(message), { + migrationCode, + code: migrationCode, + statusCode: 503, + expose: true + }); +} diff --git a/backend/src/postgresStore.js b/backend/src/postgresStore.js new file mode 100644 index 0000000..6074138 --- /dev/null +++ b/backend/src/postgresStore.js @@ -0,0 +1,482 @@ +import { Pool } from "pg"; +import { createHash, randomUUID } from "node:crypto"; + +import { getMigrationStatus } from "./migrations.js"; +import { + applyAssessment, + toPublicCard, + validateAssessmentInput +} from "./store.js"; + +const screenshotLeaseMilliseconds = 5 * 60 * 1000; + +export function createPostgresPool(databaseConfig) { + const pool = new Pool({ + connectionString: databaseConfig.connectionString, + max: databaseConfig.poolMax, + connectionTimeoutMillis: databaseConfig.connectTimeoutMs, + idleTimeoutMillis: databaseConfig.idleTimeoutMs, + application_name: "omo-api" + }); + pool.on("error", () => { + // Readiness and the next operation surface a sanitized storage error. + }); + return pool; +} + +export class PostgresCardStore { + constructor(pool, options = {}) { + this.pool = pool; + this.migrationsDirectory = options.migrationsDirectory; + } + + async list(owner) { + validateIdentifier(owner, "owner"); + return storageOperation(async () => { + const result = await this.pool.query( + `SELECT card + FROM omo_memory_cards + WHERE owner_id = $1 + ORDER BY created_at DESC, card_id`, + [owner] + ); + return result.rows.map((row) => toPublicCard(row.card)); + }); + } + + async get(owner, cardId) { + validateIdentifier(owner, "owner"); + validateIdentifier(cardId, "card"); + return storageOperation(async () => { + const result = await this.pool.query( + `SELECT card + FROM omo_memory_cards + WHERE owner_id = $1 AND card_id = $2`, + [owner, cardId] + ); + return result.rows[0] ? toPublicCard(result.rows[0].card) : null; + }); + } + + async save(owner, card) { + validateIdentifier(owner, "owner"); + validateIdentifier(card?.id, "card"); + const createdAt = timestamp(card.createdAt, "card_created_at_invalid"); + const nextReviewAt = timestamp(card.nextReviewAt, "card_review_at_invalid"); + + return storageOperation(() => withTransaction(this.pool, async (client) => { + await client.query( + `INSERT INTO omo_owners (owner_id, owner_kind) + VALUES ($1, 'device') + ON CONFLICT (owner_id) DO NOTHING`, + [owner] + ); + const inserted = await client.query( + `INSERT INTO omo_memory_cards ( + owner_id, card_id, card, created_at, next_review_at + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (owner_id, card_id) DO NOTHING + RETURNING card`, + [owner, card.id, card, createdAt, nextReviewAt] + ); + if (inserted.rows[0]) return toPublicCard(inserted.rows[0].card); + + const canonical = await client.query( + `SELECT card + FROM omo_memory_cards + WHERE owner_id = $1 AND card_id = $2`, + [owner, card.id] + ); + if (!canonical.rows[0]) { + throw storageError(503, "storage_write_conflict", "记忆卡写入发生冲突。"); + } + return toPublicCard(canonical.rows[0].card); + })); + } + + async assess(owner, cardId, assessment, attemptId) { + validateIdentifier(owner, "owner"); + validateIdentifier(cardId, "card"); + validateIdentifier(attemptId, "attempt"); + validateAssessmentInput(assessment, attemptId); + + return storageOperation(() => withTransaction(this.pool, async (client) => { + const selected = await client.query( + `SELECT card, version + FROM omo_memory_cards + WHERE owner_id = $1 AND card_id = $2 + FOR UPDATE`, + [owner, cardId] + ); + if (!selected.rows[0]) return null; + + const attempt = await client.query( + `INSERT INTO omo_assessment_attempts ( + owner_id, card_id, attempt_id, assessment + ) + VALUES ($1, $2, $3, $4) + ON CONFLICT (owner_id, card_id, attempt_id) DO NOTHING + RETURNING attempt_id`, + [owner, cardId, attemptId, assessment] + ); + if (!attempt.rows[0]) return toPublicCard(selected.rows[0].card); + + const card = structuredClone(selected.rows[0].card); + applyAssessment(card, assessment, attemptId); + const updated = await client.query( + `UPDATE omo_memory_cards + SET card = $1, + next_review_at = $2, + updated_at = NOW(), + version = version + 1 + WHERE owner_id = $3 + AND card_id = $4 + AND version = $5 + RETURNING card`, + [card, timestamp(card.nextReviewAt), owner, cardId, selected.rows[0].version] + ); + if (!updated.rows[0]) { + throw storageError(409, "storage_write_conflict", "记忆卡状态已被并发修改。"); + } + return toPublicCard(updated.rows[0].card); + })); + } + + async delete(owner, cardId) { + validateIdentifier(owner, "owner"); + validateIdentifier(cardId, "card"); + return storageOperation(async () => { + const result = await this.pool.query( + `DELETE FROM omo_memory_cards + WHERE owner_id = $1 AND card_id = $2 + RETURNING card_id`, + [owner, cardId] + ); + return Boolean(result.rows[0]); + }); + } + + async enqueueScreenshotJob(owner, { imageBase64, mimeType = "image/jpeg" } = {}) { + validateIdentifier(owner, "owner"); + validateScreenshotInput(imageBase64); + const fingerprint = screenshotFingerprint(imageBase64); + const jobId = `job-${fingerprint.slice(0, 20)}`; + + return storageOperation(() => withTransaction(this.pool, async (client) => { + await ensureOwner(client, owner); + const result = await client.query( + `INSERT INTO omo_screenshot_jobs ( + owner_id, job_id, fingerprint, state, image_base64, mime_type + ) + VALUES ($1, $2, $3, 'accepted', $4, $5) + ON CONFLICT (owner_id, fingerprint) DO UPDATE + SET updated_at = omo_screenshot_jobs.updated_at + RETURNING *`, + [owner, jobId, fingerprint, imageBase64, mimeType] + ); + return toPublicScreenshotJobRow(result.rows[0]); + })); + } + + async listScreenshotJobs(owner) { + validateIdentifier(owner, "owner"); + return storageOperation(async () => { + const result = await this.pool.query( + `SELECT * + FROM omo_screenshot_jobs + WHERE owner_id = $1 + ORDER BY created_at DESC, job_id`, + [owner] + ); + return result.rows.map(toPublicScreenshotJobRow); + }); + } + + async getScreenshotJob(owner, jobId) { + validateIdentifier(owner, "owner"); + validateIdentifier(jobId, "job"); + return storageOperation(async () => { + const result = await this.pool.query( + `SELECT * + FROM omo_screenshot_jobs + WHERE owner_id = $1 AND job_id = $2`, + [owner, jobId] + ); + return result.rows[0] ? toPublicScreenshotJobRow(result.rows[0]) : null; + }); + } + + async claimScreenshotJob(owner, jobId) { + validateIdentifier(owner, "owner"); + validateIdentifier(jobId, "job"); + return storageOperation(async () => { + const attemptToken = randomUUID(); + const leaseExpiresAt = new Date(Date.now() + screenshotLeaseMilliseconds).toISOString(); + const result = await this.pool.query( + `UPDATE omo_screenshot_jobs + SET state = 'processing', + attempt_count = attempt_count + 1, + attempt_token = $3, + lease_expires_at = $4, + updated_at = NOW() + WHERE owner_id = $1 + AND job_id = $2 + AND state = 'accepted' + AND image_base64 IS NOT NULL + RETURNING *`, + [owner, jobId, attemptToken, leaseExpiresAt] + ); + return result.rows[0] ? toInternalScreenshotJobRow(result.rows[0]) : null; + }); + } + + async renewScreenshotJobLease(owner, jobId, attemptToken) { + validateIdentifier(owner, "owner"); + validateIdentifier(jobId, "job"); + validateIdentifier(attemptToken, "attempt"); + const leaseExpiresAt = new Date(Date.now() + screenshotLeaseMilliseconds).toISOString(); + return storageOperation(async () => { + const result = await this.pool.query( + `UPDATE omo_screenshot_jobs + SET lease_expires_at = $4, updated_at = NOW() + WHERE owner_id = $1 AND job_id = $2 + AND state = 'processing' AND attempt_token = $3 + RETURNING job_id`, + [owner, jobId, attemptToken, leaseExpiresAt] + ); + return Boolean(result.rows[0]); + }); + } + + async succeedScreenshotJob(owner, jobId, attemptToken, cardId) { + validateIdentifier(owner, "owner"); + validateIdentifier(jobId, "job"); + validateIdentifier(cardId, "card"); + return storageOperation(async () => { + const result = await this.pool.query( + `UPDATE omo_screenshot_jobs + SET state = 'succeeded', + card_id = $4, + image_base64 = NULL, + attempt_token = NULL, + lease_expires_at = NULL, + error_code = '', + error_message = '', + retryable = FALSE, + updated_at = NOW() + WHERE owner_id = $1 AND job_id = $2 + AND state = 'processing' AND attempt_token = $3 + RETURNING *`, + [owner, jobId, attemptToken, cardId] + ); + return result.rows[0] ? toPublicScreenshotJobRow(result.rows[0]) : null; + }); + } + + async failScreenshotJob(owner, jobId, attemptToken, { code, message, retryable = true }) { + validateIdentifier(owner, "owner"); + validateIdentifier(jobId, "job"); + return storageOperation(async () => { + const result = await this.pool.query( + `UPDATE omo_screenshot_jobs + SET state = 'failed', + image_base64 = NULL, + attempt_token = NULL, + lease_expires_at = NULL, + error_code = $4, + error_message = $5, + retryable = $6, + updated_at = NOW() + WHERE owner_id = $1 AND job_id = $2 + AND state = 'processing' AND attempt_token = $3 + RETURNING *`, + [ + owner, + jobId, + attemptToken, + String(code || "processing_failed"), + String(message), + Boolean(retryable) + ] + ); + return result.rows[0] ? toPublicScreenshotJobRow(result.rows[0]) : null; + }); + } + + async retryScreenshotJob(owner, jobId, { imageBase64, mimeType = "image/jpeg" } = {}) { + validateIdentifier(owner, "owner"); + validateIdentifier(jobId, "job"); + validateScreenshotInput(imageBase64); + const fingerprint = screenshotFingerprint(imageBase64); + return storageOperation(() => withTransaction(this.pool, async (client) => { + const selected = await client.query( + `SELECT * FROM omo_screenshot_jobs + WHERE owner_id = $1 AND job_id = $2 + FOR UPDATE`, + [owner, jobId] + ); + if (!selected.rows[0]) return null; + if (selected.rows[0].fingerprint !== fingerprint) { + throw storageError(409, "screenshot_job_image_mismatch", "重试截图与原任务不一致。"); + } + if (selected.rows[0].state === "succeeded") { + return toPublicScreenshotJobRow(selected.rows[0]); + } + if (selected.rows[0].state === "processing") { + return toPublicScreenshotJobRow(selected.rows[0]); + } + const result = await client.query( + `UPDATE omo_screenshot_jobs + SET state = 'accepted', + image_base64 = $3, + mime_type = $4, + error_code = '', + error_message = '', + retryable = FALSE, + attempt_token = NULL, + lease_expires_at = NULL, + updated_at = NOW() + WHERE owner_id = $1 AND job_id = $2 + RETURNING *`, + [owner, jobId, imageBase64, mimeType] + ); + return toPublicScreenshotJobRow(result.rows[0]); + })); + } + + async recoverScreenshotJobs() { + return storageOperation(async () => { + const result = await this.pool.query( + `UPDATE omo_screenshot_jobs + SET state = 'accepted', + attempt_token = NULL, + lease_expires_at = NULL, + updated_at = NOW() + WHERE state = 'processing' + AND image_base64 IS NOT NULL + AND lease_expires_at <= NOW() + RETURNING *` + ); + const accepted = await this.pool.query( + `SELECT * FROM omo_screenshot_jobs + WHERE state = 'accepted' AND image_base64 IS NOT NULL + ORDER BY created_at, job_id` + ); + const rows = new Map(); + for (const row of [...result.rows, ...accepted.rows]) { + rows.set(`${row.owner_id}:${row.job_id}`, row); + } + return [...rows.values()].map((row) => ({ + owner: row.owner_id, + ...toPublicScreenshotJobRow(row) + })); + }); + } + + async readiness() { + return getMigrationStatus(this.pool, { + ...(this.migrationsDirectory + ? { migrationsDirectory: this.migrationsDirectory } + : {}) + }); + } + + async close() { + await this.pool.end(); + } +} + +async function withTransaction(pool, operation) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await operation(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } +} + +async function ensureOwner(client, owner) { + await client.query( + `INSERT INTO omo_owners (owner_id, owner_kind) + VALUES ($1, 'device') + ON CONFLICT (owner_id) DO NOTHING`, + [owner] + ); +} + +async function storageOperation(operation) { + try { + return await operation(); + } catch (error) { + if (error?.expose) throw error; + throw storageError(503, "storage_unavailable", "记忆卡存储暂时不可用。"); + } +} + +function validateIdentifier(value, kind) { + const normalized = String(value || ""); + if (!normalized || normalized.length > 200) { + throw storageError(422, `${kind}_id_invalid`, `${kind} 标识无效。`); + } +} + +function timestamp(value, code = "card_timestamp_invalid") { + const milliseconds = Date.parse(String(value || "")); + if (!Number.isFinite(milliseconds)) { + throw storageError(422, code, "记忆卡时间字段无效。"); + } + return new Date(milliseconds).toISOString(); +} + +function screenshotFingerprint(imageBase64) { + return createHash("sha256").update(imageBase64).digest("hex"); +} + +function validateScreenshotInput(imageBase64) { + if (!imageBase64 || typeof imageBase64 !== "string") { + throw storageError(400, "image_required", "请先选择一张截图。"); + } +} + +function toInternalScreenshotJobRow(row) { + return { + ...toPublicScreenshotJobRow(row), + attemptToken: row.attempt_token, + leaseExpiresAt: isoTimestamp(row.lease_expires_at), + imageBase64: row.image_base64, + mimeType: row.mime_type + }; +} + +function toPublicScreenshotJobRow(row) { + return { + id: row.job_id, + state: row.state, + createdAt: isoTimestamp(row.created_at), + updatedAt: isoTimestamp(row.updated_at), + attemptCount: Number(row.attempt_count || 0), + cardId: row.card_id || "", + errorCode: row.error_code || "", + errorMessage: row.error_message || "", + retryable: Boolean(row.retryable) + }; +} + +function isoTimestamp(value) { + return value instanceof Date ? value.toISOString() : String(value || ""); +} + +function storageError(statusCode, code, message) { + return Object.assign(new Error(message), { + statusCode, + code, + expose: true + }); +} diff --git a/backend/src/runtimeConfig.js b/backend/src/runtimeConfig.js index 29d2bf8..6b37d6d 100644 --- a/backend/src/runtimeConfig.js +++ b/backend/src/runtimeConfig.js @@ -3,6 +3,9 @@ const DEFAULT_QWEN_MODEL = "qwen3-vl-plus"; const DEFAULT_QWEN_TIMEOUT_MS = 60_000; const DEFAULT_TIKHUB_BASE_URL = "https://api.tikhub.io"; const DEFAULT_TIKHUB_TIMEOUT_MS = 15_000; +const DEFAULT_DATABASE_POOL_MAX = 10; +const DEFAULT_DATABASE_CONNECT_TIMEOUT_MS = 5_000; +const DEFAULT_DATABASE_IDLE_TIMEOUT_MS = 30_000; export function readRuntimeConfig(env = process.env) { const nodeEnv = clean(env.NODE_ENV || "development").toLowerCase(); @@ -39,6 +42,20 @@ export function readRuntimeConfig(env = process.env) { env.TIKHUB_TIMEOUT_MS || String(DEFAULT_TIKHUB_TIMEOUT_MS) ); const qwenApiKey = clean(env.QWEN_API); + const databaseURL = clean(env.DATABASE_URL); + const requestedStorageDriver = clean(env.STORE_DRIVER).toLowerCase(); + const storageDriver = requestedStorageDriver || (databaseURL ? "postgres" : "json"); + const storageDriverValid = ["json", "postgres"].includes(storageDriver); + const databaseURLValid = !databaseURL || isPostgresURL(databaseURL); + const databasePoolMax = parsePositiveInteger( + env.DATABASE_POOL_MAX || String(DEFAULT_DATABASE_POOL_MAX) + ); + const databaseConnectTimeout = parsePositiveInteger( + env.DATABASE_CONNECT_TIMEOUT_MS || String(DEFAULT_DATABASE_CONNECT_TIMEOUT_MS) + ); + const databaseIdleTimeout = parsePositiveInteger( + env.DATABASE_IDLE_TIMEOUT_MS || String(DEFAULT_DATABASE_IDLE_TIMEOUT_MS) + ); const deprecatedEnvironmentVariables = [ qwenBaseURL.legacyName, qwenModel.legacyName, @@ -71,16 +88,29 @@ export function readRuntimeConfig(env = process.env) { timeoutMs: tikhubTimeoutValue.value, timeoutValid: tikhubTimeoutValue.valid }, + database: { + connectionString: databaseURL, + configured: Boolean(databaseURL), + urlValid: databaseURLValid, + poolMax: databasePoolMax.value, + poolMaxValid: databasePoolMax.valid, + connectTimeoutMs: databaseConnectTimeout.value, + connectTimeoutValid: databaseConnectTimeout.valid, + idleTimeoutMs: databaseIdleTimeout.value, + idleTimeoutValid: databaseIdleTimeout.valid + }, storage: { - driver: "json", - durable: false, + driver: storageDriver, + driverExplicit: Boolean(requestedStorageDriver), + driverValid: storageDriverValid, + durable: storageDriver === "postgres" && Boolean(databaseURL), filePath: clean(env.CARD_STORE_PATH) }, deprecatedEnvironmentVariables }; } -export function buildReadiness(config) { +export function buildReadiness(config, runtime = {}) { const blockers = []; const demoForbidden = config.production && config.demo.requested; const modelProvider = config.qwen.configured @@ -94,10 +124,33 @@ export function buildReadiness(config) { const sourceReady = config.tikhub.configured && config.tikhub.baseURLValid && config.tikhub.timeoutValid; - const storageReady = !config.production || config.storage.durable; + const storageRequired = config.production + || config.storage.driver === "postgres" + || config.database.configured; + const storageStatus = runtime.storage || { + ready: !storageRequired && config.storage.driver === "json", + driver: config.storage.driver, + durable: config.storage.durable, + reason: storageRequired ? "storage_not_checked" : "" + }; + const storageReady = Boolean(storageStatus.ready) + && (!storageRequired || Boolean(storageStatus.durable)); if (!config.demo.valid) blockers.push("demo_mode_invalid"); if (demoForbidden) blockers.push("demo_mode_forbidden"); + if (config.production && !config.storage.driverExplicit) { + blockers.push("storage_driver_missing"); + } + if (!config.storage.driverValid) blockers.push("storage_driver_invalid"); + if (config.production && config.storage.driver !== "postgres") { + blockers.push("storage_driver_not_postgres"); + } + if (config.database.configured && config.storage.driver !== "postgres") { + blockers.push("storage_driver_mismatch"); + } + if (config.storage.driver === "postgres" && !config.database.configured) { + blockers.push("database_url_missing"); + } if (!config.qwen.configured && !config.demo.enabled) blockers.push("qwen_api_missing"); if (config.qwen.configured && !config.qwen.baseURLValid) blockers.push("qwen_base_url_invalid"); if (config.qwen.configured && !config.qwen.timeoutValid) blockers.push("qwen_timeout_invalid"); @@ -111,7 +164,28 @@ export function buildReadiness(config) { if (config.tikhub.configured && !config.tikhub.timeoutValid) { blockers.push("tikhub_timeout_invalid"); } - if (!config.storage.durable) blockers.push("durable_storage_unavailable"); + } + if (!config.database.configured && config.production) { + blockers.push("durable_storage_unavailable"); + } + if (config.database.configured && !config.database.urlValid) { + blockers.push("database_url_invalid"); + } + if (config.database.configured && !config.database.poolMaxValid) { + blockers.push("database_pool_max_invalid"); + } + if (config.database.configured && !config.database.connectTimeoutValid) { + blockers.push("database_connect_timeout_invalid"); + } + if (config.database.configured && !config.database.idleTimeoutValid) { + blockers.push("database_idle_timeout_invalid"); + } + const databaseReady = databaseConfigValid(config.database); + if (storageRequired && config.database.configured && databaseReady && !storageReady) { + blockers.push( + storageStatus.reason + || (storageStatus.durable ? "storage_unavailable" : "durable_storage_unavailable") + ); } return { @@ -130,10 +204,13 @@ export function buildReadiness(config) { provider: "tikhub" }, storage: { - required: config.production, + required: storageRequired, ready: storageReady, - driver: config.storage.driver, - durable: config.storage.durable + driver: storageStatus.driver || config.storage.driver, + durable: Boolean(storageStatus.durable), + reason: storageStatus.reason || "", + appliedVersions: storageStatus.appliedVersions || [], + pendingVersions: storageStatus.pendingVersions || [] } }, blockers, @@ -143,6 +220,13 @@ export function buildReadiness(config) { }; } +export function databaseConfigValid(database) { + return database.urlValid + && database.poolMaxValid + && database.connectTimeoutValid + && database.idleTimeoutValid; +} + function resolveEnvironmentValue(env, canonicalName, legacyName, fallback) { const canonical = clean(env[canonicalName]); if (canonical) return { value: canonical, legacyName: "" }; @@ -183,6 +267,17 @@ function parseHTTPURL(value) { } } +function isPostgresURL(value) { + try { + const parsed = new URL(value); + return ["postgres:", "postgresql:"].includes(parsed.protocol) + && Boolean(parsed.hostname) + && Boolean(parsed.pathname.replace(/\//g, "")); + } catch { + return false; + } +} + function clean(value) { return String(value || "").trim(); } diff --git a/backend/src/searchService.js b/backend/src/searchService.js new file mode 100644 index 0000000..937aa7c --- /dev/null +++ b/backend/src/searchService.js @@ -0,0 +1,130 @@ +import { readRuntimeConfig } from "./runtimeConfig.js"; + +export async function searchMemoryCards( + { query, cards = [] } = {}, + { config = readRuntimeConfig(), fetchImpl = fetch } = {} +) { + const normalizedQuery = text(query, 500); + if (!normalizedQuery) { + throw httpError(422, "search_query_required", "请输入要搜索的知识。"); + } + if (!Array.isArray(cards) || cards.length === 0) { + return { orderedCardIDs: [] }; + } + if ( + !config.qwen.configured + || !config.qwen.baseURLValid + || !config.qwen.timeoutValid + || !config.qwen.model + ) { + throw httpError(503, "search_not_configured", "知识库搜索尚未配置。"); + } + + const candidates = cards.map(toSearchDocument); + let response; + try { + response = await fetchImpl(`${config.qwen.baseURL}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${config.qwen.apiKey}`, + "content-type": "application/json" + }, + body: JSON.stringify({ + model: config.qwen.model, + temperature: 0, + response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: [ + "你是 Omo 知识库的语义检索排序器。", + "只依据给定候选卡,按用户查询的语义相关性排序。", + "只输出 JSON:{\"orderedCardIDs\":[\"id\"]}。", + "没有相关结果时返回空数组;不得生成候选集之外的 ID。" + ].join("\n") + }, + { + role: "user", + content: JSON.stringify({ query: normalizedQuery, candidates }) + } + ] + }), + signal: AbortSignal.timeout(config.qwen.timeoutMs) + }); + } catch (error) { + if (isTimeout(error)) { + throw httpError(504, "search_timeout", "知识库搜索响应超时,请重试。"); + } + throw httpError(502, "search_unavailable", "知识库搜索暂时不可用,请重试。"); + } + + if (!response.ok) { + throw httpError(502, "search_upstream_error", "知识库搜索服务暂时不可用。"); + } + + const result = await decodeResult(response); + const allowed = new Set(candidates.map((candidate) => candidate.id)); + const seen = new Set(); + const orderedCardIDs = result.orderedCardIDs.filter((id) => { + if (!allowed.has(id) || seen.has(id)) return false; + seen.add(id); + return true; + }); + return { orderedCardIDs }; +} + +async function decodeResult(response) { + let payload; + try { + payload = await response.json(); + } catch { + throw httpError(502, "search_invalid_response", "知识库搜索返回了无效结果。"); + } + const content = payload?.choices?.[0]?.message?.content; + const raw = Array.isArray(content) + ? content.map((item) => item?.text || "").join("") + : String(content || ""); + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start < 0 || end <= start) { + throw httpError(502, "search_invalid_response", "知识库搜索返回了无效结果。"); + } + try { + const result = JSON.parse(raw.slice(start, end + 1)); + if ( + !Array.isArray(result.orderedCardIDs) + || result.orderedCardIDs.some((id) => typeof id !== "string") + ) { + throw new Error("invalid schema"); + } + return result; + } catch { + throw httpError(502, "search_invalid_response", "知识库搜索返回了无效结果。"); + } +} + +function toSearchDocument(card) { + return { + id: String(card?.id || ""), + coreKnowledge: text(card?.coreKnowledge), + recallCue: text(card?.recallCue), + explanation: text(card?.explanation), + sourceTitle: text(card?.sourceTitle) + }; +} + +function text(value, limit = 600) { + return String(value || "").trim().slice(0, limit); +} + +function isTimeout(error) { + return ["AbortError", "TimeoutError"].includes(error?.name); +} + +function httpError(statusCode, code, message) { + return Object.assign(new Error(message), { + statusCode, + code, + expose: true + }); +} diff --git a/backend/src/server.js b/backend/src/server.js index f379fa0..afb39d3 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,21 +1,99 @@ import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; import { createMemoryCard } from "./cardService.js"; import { buildReadiness, readRuntimeConfig } from "./runtimeConfig.js"; -import { CardStore } from "./store.js"; +import { searchMemoryCards } from "./searchService.js"; +import { createCardStore } from "./storeFactory.js"; export function createOmoServer(options = {}) { const env = options.env || process.env; const config = readRuntimeConfig(env); - const readiness = buildReadiness(config); - const store = options.store || new CardStore( - env.CARD_STORE_PATH || resolve(".runtime/cards.json") - ); + const store = options.store || createCardStore(config, options.storeOptions); const createCard = options.createCard || createMemoryCard; + const searchCards = options.searchCards || searchMemoryCards; + const activeScreenshotJobs = new Set(); + const publicPagesDirectory = options.publicPagesDirectory + || env.OMO_PUBLIC_PAGES_DIR + || fileURLToPath(new URL("../../docs/", import.meta.url)); + const currentReadiness = async () => { + let storage; + try { + storage = await store.readiness({ production: config.production }); + } catch { + storage = { + ready: false, + driver: config.storage.driver, + durable: config.storage.durable, + reason: "storage_unavailable", + appliedVersions: [], + pendingVersions: [] + }; + } + return buildReadiness(config, { storage }); + }; + const scheduleScreenshotJob = (owner, jobId) => { + const activeKey = `${owner}:${jobId}`; + if (activeScreenshotJobs.has(activeKey)) return; + activeScreenshotJobs.add(activeKey); + setImmediate(async () => { + let claimedJob; + let leaseHeartbeat; + try { + claimedJob = await store.claimScreenshotJob(owner, jobId); + if (!claimedJob) return; + leaseHeartbeat = setInterval(async () => { + try { + await store.renewScreenshotJobLease( + owner, + jobId, + claimedJob.attemptToken + ); + } catch { + // Terminal writes are still fenced if a heartbeat cannot reach storage. + } + }, 60_000); + leaseHeartbeat.unref(); + const card = await createCard({ + imageBase64: claimedJob.imageBase64, + mimeType: claimedJob.mimeType + }, { config }); + const storedCard = await store.save(owner, card); + await store.succeedScreenshotJob( + owner, + jobId, + claimedJob.attemptToken, + storedCard.id + ); + } catch (error) { + const failure = screenshotJobFailure(error); + try { + if (claimedJob) { + await store.failScreenshotJob( + owner, + jobId, + claimedJob.attemptToken, + failure + ); + } + } catch { + // Readiness and the job list surface storage failures on the next request. + } + } finally { + if (leaseHeartbeat) clearInterval(leaseHeartbeat); + activeScreenshotJobs.delete(activeKey); + } + }); + }; + const resumeScreenshotJobs = async () => { + if (typeof store.recoverScreenshotJobs !== "function") return; + const jobs = await store.recoverScreenshotJobs(); + for (const job of jobs) scheduleScreenshotJob(job.owner || "", job.id); + }; - return createServer(async (request, response) => { + const httpServer = createServer(async (request, response) => { cors(response); if (request.method === "OPTIONS") return send(response, 204, null); @@ -36,19 +114,94 @@ export function createOmoServer(options = {}) { } if (request.method === "GET" && url.pathname === "/api/readiness") { + const readiness = await currentReadiness(); return send(response, readiness.ready ? 200 : 503, readiness); } - if (config.production && isBusinessRoute(url.pathname) && !readiness.ready) { - return send(response, 503, { - code: "service_not_ready", - message: "生产依赖尚未就绪。", - blockers: readiness.blockers - }); + const publicPage = publicPageFilename(url.pathname); + if (request.method === "GET" && publicPage) { + const html = await readFile(resolve(publicPagesDirectory, publicPage), "utf8"); + return sendHTML(response, 200, html); + } + + if ( + (config.production || config.database.configured) + && isBusinessRoute(url.pathname) + ) { + const readiness = await currentReadiness(); + if (!readiness.ready) { + return send(response, 503, { + code: "service_not_ready", + message: "服务依赖尚未就绪。", + blockers: readiness.blockers + }); + } } if (request.method === "GET" && url.pathname === "/api/memory-cards") { - return send(response, 200, { cards: store.list(owner) }); + return send(response, 200, { cards: await store.list(owner) }); + } + + if (request.method === "POST" && url.pathname === "/api/screenshot-jobs") { + const body = await readJSON(request); + const job = await store.enqueueScreenshotJob(owner, { + imageBase64: body.imageBase64, + mimeType: body.mimeType + }); + scheduleScreenshotJob(owner, job.id); + return send(response, 202, { job }); + } + + if (request.method === "GET" && url.pathname === "/api/screenshot-jobs") { + return send(response, 200, { + jobs: await store.listScreenshotJobs(owner) + }); + } + + const screenshotJobRetry = url.pathname.match( + /^\/api\/screenshot-jobs\/([^/]+)\/retry$/ + ); + if (request.method === "POST" && screenshotJobRetry) { + const jobId = decodeURIComponent(screenshotJobRetry[1]); + const body = await readJSON(request); + const job = await store.retryScreenshotJob(owner, jobId, { + imageBase64: body.imageBase64, + mimeType: body.mimeType + }); + if (!job) { + return send(response, 404, { + code: "screenshot_job_not_found", + message: "截图任务不存在。" + }); + } + scheduleScreenshotJob(owner, job.id); + return send(response, 202, { job }); + } + + const screenshotJob = url.pathname.match(/^\/api\/screenshot-jobs\/([^/]+)$/); + if (request.method === "GET" && screenshotJob) { + const job = await store.getScreenshotJob( + owner, + decodeURIComponent(screenshotJob[1]) + ); + return job + ? send(response, 200, { job }) + : send(response, 404, { + code: "screenshot_job_not_found", + message: "截图任务不存在。" + }); + } + + if (request.method === "POST" && url.pathname === "/api/memory-cards/search") { + const body = await readJSON(request); + const result = await searchCards({ + query: body.query, + cards: await store.list(owner) + }, { + config, + fetchImpl: options.searchFetchImpl || fetch + }); + return send(response, 200, result); } if (request.method === "POST" && url.pathname === "/api/sources/image-flow") { @@ -57,14 +210,14 @@ export function createOmoServer(options = {}) { imageBase64: body.imageBase64, mimeType: body.mimeType }, { config }); - store.save(owner, card); - return send(response, 200, { card: store.get(owner, card.id) }); + const storedCard = await store.save(owner, card); + return send(response, 200, { card: storedCard }); } const assessment = url.pathname.match(/^\/api\/memory-cards\/([^/]+)\/assessments$/); if (request.method === "POST" && assessment) { const body = await readJSON(request); - const card = store.assess( + const card = await store.assess( owner, decodeURIComponent(assessment[1]), body.assessment, @@ -81,7 +234,7 @@ export function createOmoServer(options = {}) { const deletion = url.pathname.match(/^\/api\/memory-cards\/([^/]+)$/); if (request.method === "DELETE" && deletion) { const cardId = decodeURIComponent(deletion[1]); - const deleted = store.delete(owner, cardId); + const deleted = await store.delete(owner, cardId); return send(response, deleted ? 200 : 404, { deleted, cardId, @@ -100,6 +253,20 @@ export function createOmoServer(options = {}) { return send(response, safeErrorStatus(error), safeErrorBody(error)); } }); + httpServer.once("listening", () => { + void resumeScreenshotJobs().catch(() => {}); + const recoveryTimer = setInterval(() => { + void resumeScreenshotJobs().catch(() => {}); + }, 30_000); + recoveryTimer.unref(); + httpServer.once("close", () => clearInterval(recoveryTimer)); + }); + if (!options.store) { + httpServer.on("close", () => { + void store.close?.(); + }); + } + return httpServer; } export const server = createOmoServer(); @@ -123,8 +290,26 @@ function safeErrorBody(error) { }; } +function screenshotJobFailure(error) { + const code = String(error?.code || "processing_failed"); + const messages = { + model_timeout: "截图处理超时,请重试。", + model_unavailable: "AI 暂时无法处理这张截图,请重试。", + model_upstream_error: "AI 暂时无法处理这张截图,请重试。", + model_invalid_response: "这张截图暂时无法生成知识卡,请重试。", + storage_unavailable: "知识卡暂时无法保存,请重试。" + }; + return { + code, + message: messages[code] || "截图处理失败,请重试。", + retryable: true + }; +} + function isBusinessRoute(pathname) { return pathname === "/api/sources/image-flow" + || pathname === "/api/screenshot-jobs" + || pathname.startsWith("/api/screenshot-jobs/") || pathname === "/api/memory-cards" || pathname.startsWith("/api/memory-cards/"); } @@ -158,10 +343,41 @@ function send(response, status, body) { response.writeHead(status); return response.end(); } - response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + response.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store" + }); response.end(JSON.stringify(body)); } +function sendHTML(response, status, body) { + response.writeHead(status, { + "content-type": "text/html; charset=utf-8", + "cache-control": "public, max-age=300", + "content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'", + "x-content-type-options": "nosniff" + }); + response.end(body); +} + +function publicPageFilename(pathname) { + if ( + pathname === "/privacy" + || pathname === "/privacy/" + || pathname === "/privacy-policy.html" + ) { + return "privacy-policy.html"; + } + if ( + pathname === "/support" + || pathname === "/support/" + || pathname === "/support.html" + ) { + return "support.html"; + } + return null; +} + function cors(response) { response.setHeader("access-control-allow-origin", "*"); response.setHeader("access-control-allow-methods", "GET,POST,DELETE,OPTIONS"); diff --git a/backend/src/store.js b/backend/src/store.js index dfea82d..4d909b8 100644 --- a/backend/src/store.js +++ b/backend/src/store.js @@ -1,26 +1,30 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; import { dirname, resolve } from "node:path"; const intervals = [0, 1, 3, 7, 14, 30]; const mastery = ["sealed", "awakened", "solidified", "engraved"]; const assessments = new Set(["remembered", "fuzzy", "forgot"]); +const screenshotLeaseMilliseconds = 5 * 60 * 1000; export class CardStore { constructor(filePath = process.env.CARD_STORE_PATH || resolve(".runtime/cards.json")) { this.filePath = filePath; - this.cards = load(filePath); + const stored = load(filePath); + this.cards = stored.cards; + this.screenshotJobs = stored.screenshotJobs; } list(owner) { return [...this.cards.values()] .filter((entry) => entry.owner === owner) .sort((a, b) => b.card.createdAt.localeCompare(a.card.createdAt)) - .map((entry) => publicCard(entry.card)); + .map((entry) => toPublicCard(entry.card)); } get(owner, cardId) { const entry = this.cards.get(key(owner, cardId)); - return entry ? publicCard(entry.card) : null; + return entry ? toPublicCard(entry.card) : null; } save(owner, card) { @@ -34,42 +38,24 @@ export class CardStore { else this.cards.delete(cardKey); throw error; } - return publicCard(card); + return toPublicCard(card); } assess(owner, cardId, assessment, attemptId) { - if (!assessments.has(assessment)) { - throw httpError(422, "assessment_invalid", "反馈只能是记得、模糊或忘记。"); - } - if (!attemptId) throw httpError(422, "attempt_id_required", "缺少反馈幂等标识。"); + validateAssessmentInput(assessment, attemptId); const entry = this.cards.get(key(owner, cardId)); if (!entry) return null; const card = entry.card; const previous = structuredClone(card); - card.attemptIds ||= []; - if (card.attemptIds.includes(attemptId)) return publicCard(card); - - card.attemptIds.push(attemptId); - card.reviewCount += 1; - if (assessment === "remembered") card.successfulRecallCount += 1; - card.lastAssessment = assessment; - - card.masteryStage = nextMasteryStage(card.masteryStage, assessment); - - const currentStep = Number(card.stepIndex || 0); - card.stepIndex = assessment === "forgot" - ? 0 - : assessment === "fuzzy" - ? Math.max(1, currentStep - 1) - : Math.min(intervals.length - 1, currentStep + 1); - card.nextReviewAt = new Date(Date.now() + intervals[card.stepIndex] * 86_400_000).toISOString(); + const applied = applyAssessment(card, assessment, attemptId); + if (!applied) return toPublicCard(card); try { this.persist(); } catch (error) { entry.card = previous; throw error; } - return publicCard(card); + return toPublicCard(card); } delete(owner, cardId) { @@ -86,15 +72,192 @@ export class CardStore { return true; } + enqueueScreenshotJob(owner, { imageBase64, mimeType = "image/jpeg" } = {}) { + validateScreenshotInput(imageBase64); + const fingerprint = screenshotFingerprint(imageBase64); + const id = `job-${fingerprint.slice(0, 20)}`; + const jobKey = key(owner, id); + const existing = this.screenshotJobs.get(jobKey); + if (existing) return toPublicScreenshotJob(existing.job); + + const now = new Date().toISOString(); + const entry = { + owner, + imageBase64, + mimeType, + job: { + id, + fingerprint, + state: "accepted", + createdAt: now, + updatedAt: now, + attemptCount: 0, + cardId: "", + errorCode: "", + errorMessage: "", + retryable: false, + attemptToken: "", + leaseExpiresAt: "" + } + }; + this.screenshotJobs.set(jobKey, entry); + try { + this.persist(); + } catch (error) { + this.screenshotJobs.delete(jobKey); + throw error; + } + return toPublicScreenshotJob(entry.job); + } + + listScreenshotJobs(owner) { + return [...this.screenshotJobs.values()] + .filter((entry) => entry.owner === owner) + .sort((a, b) => b.job.createdAt.localeCompare(a.job.createdAt)) + .map((entry) => toPublicScreenshotJob(entry.job)); + } + + getScreenshotJob(owner, jobId) { + const entry = this.screenshotJobs.get(key(owner, jobId)); + return entry ? toPublicScreenshotJob(entry.job) : null; + } + + claimScreenshotJob(owner, jobId) { + const entry = this.screenshotJobs.get(key(owner, jobId)); + if (!entry || entry.job.state !== "accepted" || !entry.imageBase64) return null; + entry.job.state = "processing"; + entry.job.attemptCount += 1; + entry.job.updatedAt = new Date().toISOString(); + entry.job.attemptToken = randomUUID(); + entry.job.leaseExpiresAt = new Date( + Date.now() + screenshotLeaseMilliseconds + ).toISOString(); + this.persist(); + return structuredClone({ + ...entry.job, + imageBase64: entry.imageBase64, + mimeType: entry.mimeType + }); + } + + renewScreenshotJobLease(owner, jobId, attemptToken, now = new Date()) { + const entry = this.screenshotJobs.get(key(owner, jobId)); + if (!entry + || entry.job.state !== "processing" + || entry.job.attemptToken !== attemptToken) return false; + entry.job.leaseExpiresAt = new Date( + now.getTime() + screenshotLeaseMilliseconds + ).toISOString(); + entry.job.updatedAt = now.toISOString(); + this.persist(); + return true; + } + + succeedScreenshotJob(owner, jobId, attemptToken, cardId) { + const entry = this.screenshotJobs.get(key(owner, jobId)); + if (!entry + || entry.job.state !== "processing" + || entry.job.attemptToken !== attemptToken) return null; + entry.job.state = "succeeded"; + entry.job.cardId = String(cardId || ""); + entry.job.errorCode = ""; + entry.job.errorMessage = ""; + entry.job.retryable = false; + entry.job.attemptToken = ""; + entry.job.leaseExpiresAt = ""; + entry.job.updatedAt = new Date().toISOString(); + entry.imageBase64 = ""; + this.persist(); + return toPublicScreenshotJob(entry.job); + } + + failScreenshotJob(owner, jobId, attemptToken, { code, message, retryable = true }) { + const entry = this.screenshotJobs.get(key(owner, jobId)); + if (!entry + || entry.job.state !== "processing" + || entry.job.attemptToken !== attemptToken) return null; + entry.job.state = "failed"; + entry.job.errorCode = String(code || "processing_failed"); + entry.job.errorMessage = String(message || "截图处理失败,请重试。"); + entry.job.retryable = Boolean(retryable); + entry.job.attemptToken = ""; + entry.job.leaseExpiresAt = ""; + entry.job.updatedAt = new Date().toISOString(); + entry.imageBase64 = ""; + this.persist(); + return toPublicScreenshotJob(entry.job); + } + + retryScreenshotJob(owner, jobId, { imageBase64, mimeType = "image/jpeg" } = {}) { + validateScreenshotInput(imageBase64); + const entry = this.screenshotJobs.get(key(owner, jobId)); + if (!entry) return null; + if (entry.job.fingerprint !== screenshotFingerprint(imageBase64)) { + throw httpError(409, "screenshot_job_image_mismatch", "重试截图与原任务不一致。"); + } + if (entry.job.state === "succeeded") return toPublicScreenshotJob(entry.job); + if (entry.job.state === "processing") return toPublicScreenshotJob(entry.job); + entry.job.state = "accepted"; + entry.job.errorCode = ""; + entry.job.errorMessage = ""; + entry.job.retryable = false; + entry.job.attemptToken = ""; + entry.job.leaseExpiresAt = ""; + entry.job.updatedAt = new Date().toISOString(); + entry.imageBase64 = imageBase64; + entry.mimeType = mimeType; + this.persist(); + return toPublicScreenshotJob(entry.job); + } + + recoverScreenshotJobs(now = new Date()) { + let changed = false; + const recovered = []; + for (const entry of this.screenshotJobs.values()) { + if (entry.job.state === "processing" + && entry.imageBase64 + && (!entry.job.leaseExpiresAt + || Date.parse(entry.job.leaseExpiresAt) <= now.getTime())) { + entry.job.state = "accepted"; + entry.job.updatedAt = now.toISOString(); + entry.job.attemptToken = ""; + entry.job.leaseExpiresAt = ""; + changed = true; + } + if (entry.job.state === "accepted" && entry.imageBase64) { + recovered.push({ owner: entry.owner, ...toPublicScreenshotJob(entry.job) }); + } + } + if (changed) this.persist(); + return recovered; + } + persist() { if (!this.filePath) return; try { mkdirSync(dirname(this.filePath), { recursive: true }); - writeFileSync(this.filePath, JSON.stringify([...this.cards.values()], null, 2)); + writeFileSync(this.filePath, JSON.stringify({ + version: 2, + cards: [...this.cards.values()], + screenshotJobs: [...this.screenshotJobs.values()] + }, null, 2)); } catch { throw httpError(503, "storage_unavailable", "记忆卡存储暂时不可用。"); } } + + async readiness({ production = false } = {}) { + return { + ready: !production, + driver: "json", + durable: false, + reason: production ? "durable_storage_unavailable" : "", + appliedVersions: [], + pendingVersions: [] + }; + } + + async close() {} } export function nextMasteryStage(currentStage, assessment) { @@ -105,23 +268,76 @@ export function nextMasteryStage(currentStage, assessment) { return mastery[Math.min(mastery.length - 1, mastery.indexOf(stage) + 1)]; } +export function applyAssessment(card, assessment, attemptId, now = Date.now()) { + validateAssessmentInput(assessment, attemptId); + card.attemptIds ||= []; + if (card.attemptIds.includes(attemptId)) return false; + + card.attemptIds.push(attemptId); + card.reviewCount = Number(card.reviewCount || 0) + 1; + if (assessment === "remembered") { + card.successfulRecallCount = Number(card.successfulRecallCount || 0) + 1; + } + card.lastAssessment = assessment; + card.masteryStage = nextMasteryStage(card.masteryStage, assessment); + + const currentStep = Number(card.stepIndex || 0); + card.stepIndex = assessment === "forgot" + ? 0 + : assessment === "fuzzy" + ? Math.max(1, currentStep - 1) + : Math.min(intervals.length - 1, currentStep + 1); + card.nextReviewAt = new Date(now + intervals[card.stepIndex] * 86_400_000).toISOString(); + return true; +} + +export function validateAssessmentInput(assessment, attemptId) { + if (!assessments.has(assessment)) { + throw httpError(422, "assessment_invalid", "反馈只能是记得、模糊或忘记。"); + } + if (!attemptId) throw httpError(422, "attempt_id_required", "缺少反馈幂等标识。"); +} + function load(filePath) { - if (!filePath || !existsSync(filePath)) return new Map(); + if (!filePath || !existsSync(filePath)) { + return { cards: new Map(), screenshotJobs: new Map() }; + } try { - return new Map(JSON.parse(readFileSync(filePath, "utf8")).map((entry) => [ - key(entry.owner, entry.card.id), - entry - ])); + const payload = JSON.parse(readFileSync(filePath, "utf8")); + const cardEntries = Array.isArray(payload) ? payload : payload.cards || []; + const screenshotJobEntries = Array.isArray(payload) ? [] : payload.screenshotJobs || []; + return { + cards: new Map(cardEntries.map((entry) => [key(entry.owner, entry.card.id), entry])), + screenshotJobs: new Map(screenshotJobEntries.map((entry) => [ + key(entry.owner, entry.job.id), + entry + ])) + }; } catch { - return new Map(); + return { cards: new Map(), screenshotJobs: new Map() }; } } -function publicCard(card) { +export function toPublicCard(card) { const { attemptIds, stepIndex, ...value } = card; return structuredClone(value); } +export function toPublicScreenshotJob(job) { + const { fingerprint, attemptToken, leaseExpiresAt, ...value } = job; + return structuredClone(value); +} + +function screenshotFingerprint(imageBase64) { + return createHash("sha256").update(imageBase64).digest("hex"); +} + +function validateScreenshotInput(imageBase64) { + if (!imageBase64 || typeof imageBase64 !== "string") { + throw httpError(400, "image_required", "请先选择一张截图。"); + } +} + function key(owner, cardId) { return `${owner}:${cardId}`; } diff --git a/backend/src/storeFactory.js b/backend/src/storeFactory.js new file mode 100644 index 0000000..af7f28e --- /dev/null +++ b/backend/src/storeFactory.js @@ -0,0 +1,74 @@ +import { resolve } from "node:path"; + +import { PostgresCardStore, createPostgresPool } from "./postgresStore.js"; +import { databaseConfigValid } from "./runtimeConfig.js"; +import { CardStore } from "./store.js"; + +export function createCardStore(config, options = {}) { + if (!config.storage.driverValid) { + return new UnavailableStore("storage_driver_invalid"); + } + if (config.storage.driver === "postgres") { + if (!config.database.configured) { + return new UnavailableStore("database_url_missing"); + } + if (!databaseConfigValid(config.database)) { + return new UnavailableStore("database_config_invalid"); + } + const pool = options.pool || createPostgresPool(config.database); + return new PostgresCardStore(pool, options); + } + if (config.database.configured) { + return new UnavailableStore("storage_driver_mismatch"); + } + return new CardStore( + config.storage.filePath || resolve(".runtime/cards.json") + ); +} + +class UnavailableStore { + constructor(reason) { + this.reason = reason; + } + + async readiness() { + return { + ready: false, + driver: "postgres", + durable: true, + reason: this.reason, + appliedVersions: [], + pendingVersions: [] + }; + } + + async list() { + throw unavailableError(); + } + + async get() { + throw unavailableError(); + } + + async save() { + throw unavailableError(); + } + + async assess() { + throw unavailableError(); + } + + async delete() { + throw unavailableError(); + } + + async close() {} +} + +function unavailableError() { + return Object.assign(new Error("数据库配置无效。"), { + statusCode: 503, + code: "database_config_invalid", + expose: true + }); +} diff --git a/backend/test/cardService.test.js b/backend/test/cardService.test.js index dc229d5..b3a3d21 100644 --- a/backend/test/cardService.test.js +++ b/backend/test/cardService.test.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createMemoryCard } from "../src/cardService.js"; +import { createMemoryCard, hasValidHiddenSemantic } from "../src/cardService.js"; import { readRuntimeConfig } from "../src/runtimeConfig.js"; test("missing Qwen configuration fails instead of returning a demo success", async () => { @@ -22,6 +22,8 @@ test("fixture cards require an explicit non-production flag", async () => { assert.equal(card.rarity, "R"); assert.equal(card.generationMode, "fixture"); + assert.equal(card.hiddenSemantic, "再次想起"); + assert.equal(card.answer, card.hiddenSemantic); assert.equal(card.sourceTitle, "本地 Fixture 卡"); assert.equal(card.sourceReason, "provider_missing"); assert.match(card.explanation, /Fixture/); @@ -65,8 +67,8 @@ test("creates a card from a valid Qwen response without fabricating source verif const config = qwenConfig(); const fetchImpl = async () => qwenResponse({ coreKnowledge: "可见知识", + hiddenSemantic: "知识", recallCue: "你看到了什么?", - answer: "可见答案", explanation: "仅依据截图。", sourceTitle: "截图标题", sourceAccount: "截图作者", @@ -86,10 +88,77 @@ test("creates a card from a valid Qwen response without fabricating source verif ); assert.equal(card.coreKnowledge, "可见知识"); + assert.equal(card.hiddenSemantic, "知识"); + assert.equal(card.answer, "知识"); assert.equal(card.generationMode, "qwen"); assert.equal(card.sourceStatus, "screenshot_only"); assert.equal(card.sourceReason, "provider_unavailable"); - assert.equal(card.rarity, "SR"); + assert.equal(card.rarity, "R"); +}); + +test("repairs one invalid hidden semantic using the same screenshot", async () => { + const requests = []; + const fetchImpl = async (_url, options) => { + requests.push(JSON.parse(options.body)); + return qwenResponse(requests.length === 1 ? { + ...validGeneratedCard(), + hiddenSemantic: "截图中不存在的词" + } : validGeneratedCard()); + }; + + const card = await createMemoryCard( + { imageBase64: "aGVsbG8=", mimeType: "image/png" }, + { + config: qwenConfig(), + fetchImpl, + verifySourceImpl: async () => verifiedSource() + } + ); + + assert.equal(requests.length, 2); + assert.match(requests[1].messages[1].content[0].text, /连续子串/); + assert.equal(requests[1].messages[1].content[1].image_url.url, "data:image/png;base64,aGVsbG8="); + assert.equal(card.hiddenSemantic, "认知卸载"); + assert.equal(card.answer, card.hiddenSemantic); +}); + +test("rejects a second invalid hidden semantic before source verification", async () => { + let modelCalls = 0; + let sourceCalls = 0; + const fetchImpl = async () => { + modelCalls += 1; + return qwenResponse({ + ...validGeneratedCard(), + hiddenSemantic: "截图中不存在的词" + }); + }; + + await assert.rejects( + createMemoryCard( + { imageBase64: "aGVsbG8=" }, + { + config: qwenConfig(), + fetchImpl, + verifySourceImpl: async () => { + sourceCalls += 1; + return verifiedSource(); + } + } + ), + (error) => error.statusCode === 502 && error.code === "model_invalid_response" + ); + + assert.equal(modelCalls, 2); + assert.equal(sourceCalls, 0); +}); + +test("validates hidden semantic as a non-empty exact substring", () => { + assert.equal(hasValidHiddenSemantic(validGeneratedCard()), true); + assert.equal(hasValidHiddenSemantic({ ...validGeneratedCard(), hiddenSemantic: "" }), false); + assert.equal( + hasValidHiddenSemantic({ ...validGeneratedCard(), hiddenSemantic: "认知 卸载" }), + false + ); }); test("Qwen timeout maps to a stable 504 without exposing request data", async () => { @@ -155,3 +224,28 @@ function qwenResponse(card) { headers: { "content-type": "application/json" } }); } + +function validGeneratedCard() { + return { + coreKnowledge: "截图可能削弱记忆,因为它会触发认知卸载。", + hiddenSemantic: "认知卸载", + recallCue: "为什么保存截图有时反而更难记住?", + explanation: "用户认为设备已经替自己保存,因此减少主动编码。", + sourceTitle: "截图与记忆", + sourceAccount: "学习研究所", + platform: "wechat", + rarity: "SR" + }; +} + +function verifiedSource() { + return { + status: "verified", + provider: "tikhub", + platform: "wechat", + title: "截图与记忆", + account: "学习研究所", + url: "https://example.com/article", + confidence: 0.9 + }; +} diff --git a/backend/test/deploymentConfig.test.js b/backend/test/deploymentConfig.test.js new file mode 100644 index 0000000..d37a69f --- /dev/null +++ b/backend/test/deploymentConfig.test.js @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const repositoryRoot = new URL("../../", import.meta.url); + +test("Railway staging builds the backend from an explicit Dockerfile", async () => { + const railwayConfig = JSON.parse( + await readFile(new URL("railway.json", repositoryRoot), "utf8") + ); + const dockerfile = await readFile(new URL("Dockerfile", repositoryRoot), "utf8"); + + assert.equal(railwayConfig.build?.builder, "DOCKERFILE"); + assert.equal(railwayConfig.build?.dockerfilePath, "Dockerfile"); + assert.equal(railwayConfig.deploy?.preDeployCommand, "npm run db:migrate"); + assert.match(dockerfile, /^FROM node:20-alpine$/m); + assert.match( + dockerfile, + /^COPY backend\/package\.json backend\/package-lock\.json \.\/$/m + ); + assert.match(dockerfile, /^RUN npm ci --omit=dev$/m); + assert.match(dockerfile, /^COPY backend\/ \.\/$/m); + assert.match(dockerfile, /^CMD \["npm", "start"\]$/m); +}); + +test("Railway container listens on every interface", async () => { + const dockerfile = await readFile(new URL("Dockerfile", repositoryRoot), "utf8"); + + assert.match(dockerfile, /^ENV HOST=0\.0\.0\.0$/m); +}); + +test("Release builds target only the verified TestFlight staging API", async () => { + const project = await readFile( + new URL("Omo/Omo.xcodeproj/project.pbxproj", repositoryRoot), + "utf8" + ); + + assert.match( + project, + /"?OMO_API_BASE_URL"? = "https:\/\/omo-api-staging-staging\.up\.railway\.app";/ + ); + assert.doesNotMatch(project, /shibei-production\.up\.railway\.app/); +}); + +test("TestFlight export remains eligible for external beta review", async () => { + const exportOptions = await readFile( + new URL("config/ExportOptions-TestFlight.plist", repositoryRoot), + "utf8" + ); + + assert.match(exportOptions, /testFlightInternalTestingOnly<\/key>\s*/); + assert.doesNotMatch(exportOptions, /testFlightInternalTestingOnly<\/key>\s*/); +}); + +test("Omo uses an independent app identity and build sequence", async () => { + const project = await readFile( + new URL("Omo/Omo.xcodeproj/project.pbxproj", repositoryRoot), + "utf8" + ); + + const buildSettings = [...project.matchAll(/buildSettings = \{([\s\S]*?)\n\s*\};/g)] + .map((match) => match[1]); + const buildVersions = (bundleIdentifier) => buildSettings + .filter((settings) => settings.match(/"?PRODUCT_BUNDLE_IDENTIFIER"? = "?([^";]+)"?;/)?.[1] === bundleIdentifier) + .map((settings) => settings.match(/"?CURRENT_PROJECT_VERSION"? = (\d+);/)?.[1]) + .filter(Boolean); + + assert.deepEqual(buildVersions("com.maxhan.omo"), ["3", "3"]); + assert.deepEqual(buildVersions("com.maxhan.omo.Tests"), ["1", "1"]); + assert.deepEqual(buildVersions("com.maxhan.omo.UITests"), ["1", "1"]); + assert.doesNotMatch(project, /com\.maxhan\.shibei/); +}); diff --git a/backend/test/migrations.test.js b/backend/test/migrations.test.js new file mode 100644 index 0000000..d967c37 --- /dev/null +++ b/backend/test/migrations.test.js @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { loadMigrations } from "../src/migrations.js"; + +test("migration set includes durable screenshot jobs after assessment idempotency", async () => { + const migrations = await loadMigrations(); + assert.deepEqual(migrations.map((migration) => migration.version), ["001", "002", "003"]); + assert.match(migrations[2].sql, /CREATE TABLE omo_screenshot_jobs/); + assert.match(migrations[2].sql, /UNIQUE \(owner_id, fingerprint\)/); +}); diff --git a/backend/test/postgres.integration.test.js b/backend/test/postgres.integration.test.js new file mode 100644 index 0000000..621fe5d --- /dev/null +++ b/backend/test/postgres.integration.test.js @@ -0,0 +1,293 @@ +import assert from "node:assert/strict"; +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import test from "node:test"; +import { Pool } from "pg"; + +import { importJsonCards } from "../src/importJsonStore.js"; +import { getMigrationStatus, runMigrations } from "../src/migrations.js"; +import { PostgresCardStore } from "../src/postgresStore.js"; +import { createOmoServer } from "../src/server.js"; + +const databaseURL = process.env.TEST_DATABASE_URL || ""; +const resetAllowed = process.env.OMO_ALLOW_TEST_DATABASE_RESET === "1"; + +test("PostgreSQL migrations, persistence, concurrency and import", { + skip: !databaseURL +}, async (context) => { + assert.equal(resetAllowed, true, "OMO_ALLOW_TEST_DATABASE_RESET=1 is required"); + const parsedURL = new URL(databaseURL); + assert.ok(["127.0.0.1", "localhost"].includes(parsedURL.hostname)); + assert.match(parsedURL.pathname, /^\/omo_test_/); + + let pool = createPool(); + await resetSchema(pool); + await verifyFailedMigrationRollback(pool, context); + + const [firstRunner, secondRunner] = await Promise.all([ + runMigrations(pool, { targetVersion: "001" }), + runMigrations(pool, { targetVersion: "001" }) + ]); + assert.deepEqual( + [...firstRunner.newlyApplied, ...secondRunner.newlyApplied].sort(), + ["001"] + ); + const upgradeRequired = await getMigrationStatus(pool); + assert.equal(upgradeRequired.ready, false); + assert.deepEqual(upgradeRequired.pendingVersions, ["002", "003"]); + + const upgraded = await runMigrations(pool); + assert.deepEqual(upgraded.newlyApplied, ["002", "003"]); + assert.equal((await getMigrationStatus(pool)).ready, true); + await verifyChecksumDrift(pool, context); + + let store = new PostgresCardStore(pool); + const card = memoryCard("card-canonical"); + await store.save("device-a", card); + const duplicate = await store.save("device-a", { + ...card, + answer: "不应覆盖 canonical 卡片" + }); + assert.equal(duplicate.answer, "合成答案"); + await store.close(); + + pool = createPool(); + store = new PostgresCardStore(pool); + assert.equal((await store.get("device-a", card.id)).answer, "合成答案"); + await verifyScreenshotJobLifecycle(store, pool); + + const firstAssessment = await store.assess( + "device-a", + card.id, + "remembered", + "attempt-idempotent" + ); + const repeatedAssessment = await store.assess( + "device-a", + card.id, + "forgot", + "attempt-idempotent" + ); + assert.equal(firstAssessment.reviewCount, 1); + assert.equal(repeatedAssessment.reviewCount, 1); + assert.equal(repeatedAssessment.lastAssessment, "remembered"); + + await Promise.all([ + store.assess("device-a", card.id, "remembered", "attempt-concurrent-a"), + store.assess("device-a", card.id, "remembered", "attempt-concurrent-b") + ]); + const assessed = await store.get("device-a", card.id); + assert.equal(assessed.reviewCount, 3); + assert.equal(assessed.successfulRecallCount, 3); + + const raceCard = memoryCard("card-delete-race"); + await store.save("device-a", raceCard); + const race = await Promise.allSettled([ + store.assess("device-a", raceCard.id, "remembered", "attempt-race"), + store.delete("device-a", raceCard.id) + ]); + assert.equal(race.every((result) => result.status === "fulfilled"), true); + assert.equal(await store.get("device-a", raceCard.id), null); + + const importDirectory = mkdtempSync(join(tmpdir(), "omo-import-")); + context.after(() => rmSync(importDirectory, { recursive: true, force: true })); + const importFile = join(importDirectory, "cards.json"); + writeFileSync(importFile, JSON.stringify([ + { owner: "device-import", card: memoryCard("card-imported") } + ])); + const dryRun = await importJsonCards({ filePath: importFile, store, dryRun: true }); + assert.deepEqual(dryRun, { scanned: 1, imported: 1, existing: 0, dryRun: true }); + assert.equal(await store.get("device-import", "card-imported"), null); + const imported = await importJsonCards({ filePath: importFile, store }); + assert.deepEqual(imported, { scanned: 1, imported: 1, existing: 0, dryRun: false }); + const importedAgain = await importJsonCards({ filePath: importFile, store }); + assert.deepEqual(importedAgain, { scanned: 1, imported: 0, existing: 1, dryRun: false }); + + await store.close(); + await verifyServiceRestart(); + + pool = createPool(); + store = new PostgresCardStore(pool); + await store.save("recovery-owner", memoryCard("recovery-card")); + assert.equal((await store.readiness()).ready, true); + await store.close(); +}); + +async function verifyChecksumDrift(pool, context) { + const directory = mkdtempSync(join(tmpdir(), "omo-migrations-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + const migrationsDirectory = new URL("../migrations/", import.meta.url); + for (const name of [ + "001-create-owners-and-memory-cards.sql", + "002-add-assessment-idempotency-and-version.sql", + "003-create-screenshot-jobs.sql" + ]) { + copyFileSync(new URL(name, migrationsDirectory), join(directory, basename(name))); + } + const firstMigration = join(directory, "001-create-owners-and-memory-cards.sql"); + writeFileSync(firstMigration, `${readFileSync(firstMigration, "utf8")}\n-- drift\n`); + const status = await getMigrationStatus(pool, { migrationsDirectory: directory }); + assert.equal(status.ready, false); + assert.equal(status.reason, "storage_migration_drift"); +} + +async function verifyScreenshotJobLifecycle(store, pool) { + const accepted = await store.enqueueScreenshotJob("job-owner", { + imageBase64: "cG9zdGdyZXMtbGVhc2U=", + mimeType: "image/jpeg" + }); + const claimed = await store.claimScreenshotJob("job-owner", accepted.id); + assert.ok(claimed.attemptToken); + assert.equal( + await store.failScreenshotJob("job-owner", accepted.id, "stale-token", { + code: "model_timeout", + message: "截图处理超时,请重试。" + }), + null + ); + const succeeded = await store.succeedScreenshotJob( + "job-owner", + accepted.id, + claimed.attemptToken, + "job-card" + ); + assert.equal(succeeded.state, "succeeded"); + const stored = await pool.query( + `SELECT image_base64, attempt_token, lease_expires_at + FROM omo_screenshot_jobs WHERE owner_id = $1 AND job_id = $2`, + ["job-owner", accepted.id] + ); + assert.equal(stored.rows[0].image_base64, null); + assert.equal(stored.rows[0].attempt_token, null); + assert.equal(stored.rows[0].lease_expires_at, null); +} + +async function verifyFailedMigrationRollback(pool, context) { + const directory = mkdtempSync(join(tmpdir(), "omo-broken-migration-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + writeFileSync( + join(directory, "001-broken-migration.sql"), + `CREATE TABLE migration_rollback_probe (id INTEGER); + SELECT * FROM table_that_does_not_exist;` + ); + + await assert.rejects( + runMigrations(pool, { migrationsDirectory: directory }), + (error) => error.migrationCode === "migration_failed" + ); + const result = await pool.query( + `SELECT + to_regclass('public.migration_rollback_probe') AS probe, + to_regclass('public.omo_schema_migrations') AS migrations` + ); + assert.equal(result.rows[0].probe, null); + assert.equal(result.rows[0].migrations, null); +} + +async function resetSchema(pool) { + await pool.query("DROP TABLE IF EXISTS omo_screenshot_jobs CASCADE"); + await pool.query("DROP TABLE IF EXISTS omo_assessment_attempts CASCADE"); + await pool.query("DROP TABLE IF EXISTS omo_memory_cards CASCADE"); + await pool.query("DROP TABLE IF EXISTS omo_owners CASCADE"); + await pool.query("DROP TABLE IF EXISTS omo_schema_migrations CASCADE"); +} + +async function verifyServiceRestart() { + let pool = createPool(); + let store = new PostgresCardStore(pool); + let server = createOmoServer({ + env: { + NODE_ENV: "development", + OMO_DEMO_MODE: "1", + DATABASE_URL: databaseURL + }, + store + }); + let baseURL = await listen(server); + const created = await fetch(`${baseURL}/api/sources/image-flow`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-id": "service-restart-owner" + }, + body: JSON.stringify({ imageBase64: "c2VydmljZS1yZXN0YXJ0" }) + }); + assert.equal(created.status, 200); + const createdCard = (await created.json()).card; + await closeServer(server); + await store.close(); + + pool = createPool(); + store = new PostgresCardStore(pool); + server = createOmoServer({ + env: { + NODE_ENV: "development", + OMO_DEMO_MODE: "1", + DATABASE_URL: databaseURL + }, + store + }); + baseURL = await listen(server); + const readback = await fetch(`${baseURL}/api/memory-cards`, { + headers: { "x-device-id": "service-restart-owner" } + }); + const cards = (await readback.json()).cards; + assert.equal(readback.status, 200); + assert.equal(cards.some((card) => card.id === createdCard.id), true); + await closeServer(server); + await store.close(); +} + +function listen(server) { + return new Promise((resolveURL, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + resolveURL(`http://127.0.0.1:${server.address().port}`); + }); + }); +} + +function closeServer(server) { + return new Promise((resolveClose, reject) => { + server.close((error) => error ? reject(error) : resolveClose()); + }); +} + +function createPool() { + return new Pool({ + connectionString: databaseURL, + max: 6, + connectionTimeoutMillis: 2_000, + idleTimeoutMillis: 1_000 + }); +} + +function memoryCard(id) { + const now = new Date().toISOString(); + return { + id, + generationMode: "fixture", + coreKnowledge: "合成知识", + recallCue: "合成提示", + answer: "合成答案", + explanation: "合成解释", + sourceTitle: "合成来源", + sourceAccount: "", + sourcePlatform: "unknown", + sourceUrl: "", + sourceStatus: "screenshot_only", + sourceProvider: "tikhub", + sourceReason: "provider_missing", + sourceConfidence: 0, + rarity: "R", + createdAt: now, + masteryStage: "sealed", + nextReviewAt: now, + reviewCount: 0, + successfulRecallCount: 0, + lastAssessment: null, + stepIndex: 0, + attemptIds: [] + }; +} diff --git a/backend/test/postgresStore.test.js b/backend/test/postgresStore.test.js new file mode 100644 index 0000000..6b6ee42 --- /dev/null +++ b/backend/test/postgresStore.test.js @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PostgresCardStore } from "../src/postgresStore.js"; + +test("stale Postgres updates fail with a stable conflict error", async () => { + const queries = []; + const client = { + async query(sql) { + queries.push(sql); + if (sql.includes("SELECT card, version")) { + return { rows: [{ card: memoryCard(), version: "1" }] }; + } + if (sql.includes("INSERT INTO omo_assessment_attempts")) { + return { rows: [{ attempt_id: "attempt-1" }] }; + } + if (sql.includes("UPDATE omo_memory_cards")) return { rows: [] }; + return { rows: [] }; + }, + release() {} + }; + const pool = { async connect() { return client; } }; + const store = new PostgresCardStore(pool); + + await assert.rejects( + store.assess("device-a", "card-1", "remembered", "attempt-1"), + (error) => error.statusCode === 409 && error.code === "storage_write_conflict" + ); + assert.ok(queries.includes("ROLLBACK")); +}); + +test("driver errors are sanitized before reaching the API boundary", async () => { + const pool = { + async query() { + throw new Error("postgres://user:secret@private-host/database"); + } + }; + const store = new PostgresCardStore(pool); + + await assert.rejects( + store.list("device-a"), + (error) => error.statusCode === 503 + && error.code === "storage_unavailable" + && !error.message.includes("private-host") + ); +}); + +test("failed Postgres writes roll back and return a stable storage error", async () => { + const queries = []; + const client = { + async query(sql) { + queries.push(sql); + if (sql.includes("INSERT INTO omo_memory_cards")) { + throw new Error("postgresql://user:secret@private-host/database"); + } + return { rows: [] }; + }, + release() {} + }; + const pool = { async connect() { return client; } }; + const store = new PostgresCardStore(pool); + + await assert.rejects( + store.save("device-a", memoryCard()), + (error) => error.statusCode === 503 + && error.code === "storage_unavailable" + && !error.message.includes("private-host") + ); + assert.ok(queries.includes("ROLLBACK")); +}); + +test("Postgres screenshot jobs preserve private image data while exposing only public state", async () => { + const queries = []; + const now = "2026-08-08T00:00:00.000Z"; + const row = { + owner_id: "device-a", + job_id: "job-90ddc7d246434f636213", + fingerprint: "90ddc7d246434f636213c69cd39c1604962ccbcca84b7bd9ebd4563fbba6834b", + state: "accepted", + image_base64: "cG9zdGdyZXMtaW1hZ2U=", + mime_type: "image/jpeg", + attempt_count: 0, + card_id: "", + error_code: "", + error_message: "", + retryable: false, + created_at: now, + updated_at: now + }; + const client = { + async query(sql) { + queries.push(sql); + if (sql.includes("INSERT INTO omo_screenshot_jobs")) return { rows: [row] }; + return { rows: [] }; + }, + release() {} + }; + const pool = { async connect() { return client; } }; + const store = new PostgresCardStore(pool); + + const job = await store.enqueueScreenshotJob("device-a", { + imageBase64: "cG9zdGdyZXMtaW1hZ2U=", + mimeType: "image/jpeg" + }); + + assert.deepEqual(job, { + id: "job-90ddc7d246434f636213", + state: "accepted", + createdAt: now, + updatedAt: now, + attemptCount: 0, + cardId: "", + errorCode: "", + errorMessage: "", + retryable: false + }); + assert.equal(JSON.stringify(job).includes("cG9zdGdyZXMtaW1hZ2U="), false); + assert.ok(queries.some((sql) => sql.includes("ON CONFLICT (owner_id, fingerprint)"))); +}); + +test("Postgres screenshot completion is fenced by the active attempt token", async () => { + const queries = []; + const now = "2026-08-08T00:00:00.000Z"; + const client = { + async query(sql, values) { + queries.push({ sql, values }); + if (sql.includes("SET state = 'processing'")) { + return { rows: [{ + owner_id: "device-a", + job_id: "job-a", + fingerprint: "a".repeat(64), + state: "processing", + image_base64: "aW1hZ2U=", + mime_type: "image/jpeg", + attempt_count: 1, + attempt_token: values[2], + lease_expires_at: values[3], + card_id: "", + error_code: "", + error_message: "", + retryable: false, + created_at: now, + updated_at: now + }] }; + } + if (sql.includes("SET state = 'succeeded'")) return { rows: [] }; + return { rows: [] }; + }, + release() {} + }; + const pool = { async connect() { return client; }, query: client.query }; + const store = new PostgresCardStore(pool); + + const claimed = await store.claimScreenshotJob("device-a", "job-a"); + assert.ok(claimed.attemptToken); + assert.equal( + await store.succeedScreenshotJob("device-a", "job-a", "stale-token", "card-a"), + null + ); + const completion = queries.find(({ sql }) => sql.includes("SET state = 'succeeded'")); + assert.match(completion.sql, /state = 'processing' AND attempt_token = \$3/); + assert.equal(completion.values[2], "stale-token"); +}); + +function memoryCard(id = "card-1") { + const now = new Date().toISOString(); + return { + id, + generationMode: "fixture", + coreKnowledge: "合成知识", + recallCue: "合成提示", + answer: "合成答案", + explanation: "合成解释", + sourceTitle: "合成来源", + sourceAccount: "", + sourcePlatform: "unknown", + sourceUrl: "", + sourceStatus: "screenshot_only", + sourceProvider: "tikhub", + sourceReason: "provider_missing", + sourceConfidence: 0, + rarity: "R", + createdAt: now, + masteryStage: "sealed", + nextReviewAt: now, + reviewCount: 0, + successfulRecallCount: 0, + lastAssessment: null, + stepIndex: 0, + attemptIds: [] + }; +} diff --git a/backend/test/runtimeConfig.test.js b/backend/test/runtimeConfig.test.js index 8f4db90..03a00dc 100644 --- a/backend/test/runtimeConfig.test.js +++ b/backend/test/runtimeConfig.test.js @@ -48,6 +48,8 @@ test("invalid provider URLs and timeouts are explicit readiness blockers", () => })); assert.deepEqual(report.blockers, [ + "storage_driver_missing", + "storage_driver_not_postgres", "qwen_base_url_invalid", "qwen_timeout_invalid", "tikhub_base_url_invalid", @@ -83,3 +85,81 @@ test("legacy aliases remain compatible but are reported by name only", () => { ]); assert.equal(JSON.stringify(report).includes("legacy-secret"), false); }); + +test("configured PostgreSQL is not ready until connection and migrations pass", () => { + const config = readRuntimeConfig({ + NODE_ENV: "production", + STORE_DRIVER: "postgres", + QWEN_API: "qwen-secret", + TIKHUB_API_KEY: "tikhub-secret", + DATABASE_URL: "postgresql://omo:database-secret@db.example/omo" + }); + const unchecked = buildReadiness(config); + const ready = buildReadiness(config, { + storage: { + ready: true, + driver: "postgres", + durable: true, + reason: "", + appliedVersions: ["001", "002"], + pendingVersions: [] + } + }); + + assert.ok(unchecked.blockers.includes("storage_not_checked")); + assert.equal(ready.ready, true); + assert.equal(ready.checks.storage.driver, "postgres"); + assert.deepEqual(ready.checks.storage.appliedVersions, ["001", "002"]); + assert.equal(JSON.stringify(ready).includes("database-secret"), false); +}); + +test("production requires an explicit PostgreSQL driver and rejects mismatches", () => { + const missing = buildReadiness(readRuntimeConfig({ + NODE_ENV: "production", + QWEN_API: "qwen-secret", + TIKHUB_API_KEY: "tikhub-secret", + DATABASE_URL: "postgresql://omo:database-secret@db.example/omo" + })); + const mismatched = buildReadiness(readRuntimeConfig({ + NODE_ENV: "production", + STORE_DRIVER: "json", + QWEN_API: "qwen-secret", + TIKHUB_API_KEY: "tikhub-secret", + DATABASE_URL: "postgresql://omo:database-secret@db.example/omo" + })); + + assert.ok(missing.blockers.includes("storage_driver_missing")); + assert.ok(mismatched.blockers.includes("storage_driver_not_postgres")); + assert.ok(mismatched.blockers.includes("storage_driver_mismatch")); + assert.equal(JSON.stringify(mismatched).includes("database-secret"), false); +}); + +test("invalid storage driver is rejected without falling back", () => { + const report = buildReadiness(readRuntimeConfig({ + NODE_ENV: "development", + OMO_DEMO_MODE: "1", + STORE_DRIVER: "sqlite" + })); + + assert.ok(report.blockers.includes("storage_driver_invalid")); + assert.equal(report.checks.storage.driver, "sqlite"); +}); + +test("invalid database URL and pool settings fail readiness explicitly", () => { + const report = buildReadiness(readRuntimeConfig({ + NODE_ENV: "development", + OMO_DEMO_MODE: "1", + DATABASE_URL: "https://user:secret@example.com/not-postgres", + DATABASE_POOL_MAX: "0", + DATABASE_CONNECT_TIMEOUT_MS: "invalid", + DATABASE_IDLE_TIMEOUT_MS: "-1" + })); + + assert.deepEqual(report.blockers, [ + "database_url_invalid", + "database_pool_max_invalid", + "database_connect_timeout_invalid", + "database_idle_timeout_invalid" + ]); + assert.equal(JSON.stringify(report).includes("secret"), false); +}); diff --git a/backend/test/screenshotJobs.test.js b/backend/test/screenshotJobs.test.js new file mode 100644 index 0000000..99c498b --- /dev/null +++ b/backend/test/screenshotJobs.test.js @@ -0,0 +1,300 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { createOmoServer } from "../src/server.js"; +import { CardStore } from "../src/store.js"; + +test("screenshot job is accepted before slow card generation completes", async () => { + const generation = deferred(); + const store = new CardStore(""); + + await withServer(async (baseURL) => { + const responsePromise = createJob(baseURL, "slow-owner", "c2xvdy1pbWFnZQ=="); + const response = await Promise.race([ + responsePromise, + delay(150).then(() => null) + ]); + + assert.notEqual(response, null, "job acceptance must not wait for model generation"); + assert.equal(response.status, 202); + const accepted = (await response.json()).job; + assert.equal(accepted.id, "job-fd6112941b78fcd21110"); + assert.ok(["accepted", "processing"].includes(accepted.state)); + assert.equal(JSON.stringify(accepted).includes("c2xvdy1pbWFnZQ=="), false); + + generation.resolve(memoryCard("card-slow")); + const completed = await waitForJob(baseURL, "slow-owner", accepted.id, "succeeded"); + assert.equal(completed.cardId, "card-slow"); + + const cards = await fetch(`${baseURL}/api/memory-cards`, { + headers: { "x-device-id": "slow-owner" } + }); + assert.equal((await cards.json()).cards[0].id, "card-slow"); + }, { store, createCard: () => generation.promise }); +}); + +test("failed screenshot job is visible, sanitized and retryable with the same id", async () => { + let attempts = 0; + const store = new CardStore(""); + const createCard = async () => { + attempts += 1; + if (attempts === 1) { + throw Object.assign(new Error("private upstream detail"), { + statusCode: 504, + code: "model_timeout", + expose: true + }); + } + return memoryCard("card-retried"); + }; + + await withServer(async (baseURL) => { + const acceptedResponse = await createJob(baseURL, "retry-owner", "cmV0cnktaW1hZ2U="); + assert.equal(acceptedResponse.status, 202); + const accepted = (await acceptedResponse.json()).job; + const failed = await waitForJob(baseURL, "retry-owner", accepted.id, "failed"); + + assert.equal(failed.errorCode, "model_timeout"); + assert.equal(failed.errorMessage, "截图处理超时,请重试。"); + assert.equal(failed.retryable, true); + assert.equal(JSON.stringify(failed).includes("private upstream detail"), false); + + const retryResponse = await fetch( + `${baseURL}/api/screenshot-jobs/${accepted.id}/retry`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-id": "retry-owner" + }, + body: JSON.stringify({ + imageBase64: "cmV0cnktaW1hZ2U=", + mimeType: "image/jpeg" + }) + } + ); + assert.equal(retryResponse.status, 202); + assert.equal((await retryResponse.json()).job.id, accepted.id); + + const succeeded = await waitForJob(baseURL, "retry-owner", accepted.id, "succeeded"); + assert.equal(succeeded.cardId, "card-retried"); + assert.equal(succeeded.attemptCount, 2); + }, { store, createCard }); +}); + +test("duplicate screenshot submissions converge on one canonical job", async () => { + const generation = deferred(); + let calls = 0; + + await withServer(async (baseURL) => { + const responses = await Promise.all([ + createJob(baseURL, "duplicate-owner", "c2FtZS1pbWFnZQ=="), + createJob(baseURL, "duplicate-owner", "c2FtZS1pbWFnZQ==") + ]); + const jobs = await Promise.all(responses.map(async (response) => { + assert.equal(response.status, 202); + return (await response.json()).job; + })); + + assert.equal(jobs[0].id, jobs[1].id); + await waitUntil(() => calls === 1); + assert.equal(calls, 1); + + generation.resolve(memoryCard("card-canonical-job")); + await waitForJob(baseURL, "duplicate-owner", jobs[0].id, "succeeded"); + assert.equal(calls, 1); + }, { + store: new CardStore(""), + createCard: async () => { + calls += 1; + return generation.promise; + } + }); +}); + +test("persisted processing job is recovered after store restart", async (context) => { + const directory = mkdtempSync(join(tmpdir(), "omo-screenshot-jobs-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + const filePath = join(directory, "store.json"); + + const firstStore = new CardStore(filePath); + const accepted = await firstStore.enqueueScreenshotJob("restart-owner", { + imageBase64: "cmVzdGFydC1pbWFnZQ==", + mimeType: "image/jpeg" + }); + const claimed = await firstStore.claimScreenshotJob("restart-owner", accepted.id); + assert.equal(claimed.state, "processing"); + + const restartedStore = new CardStore(filePath); + const unexpired = await restartedStore.recoverScreenshotJobs(); + assert.deepEqual(unexpired, []); + assert.equal( + (await restartedStore.getScreenshotJob("restart-owner", accepted.id)).state, + "processing" + ); + + const recovered = await restartedStore.recoverScreenshotJobs( + new Date(Date.now() + 10 * 60 * 1000) + ); + assert.deepEqual(recovered.map((job) => job.id), [accepted.id]); + assert.equal( + (await restartedStore.getScreenshotJob("restart-owner", accepted.id)).state, + "accepted" + ); +}); + +test("only the active claim token can complete a screenshot attempt", async () => { + const store = new CardStore(""); + const accepted = store.enqueueScreenshotJob("lease-owner", { + imageBase64: "bGVhc2UtaW1hZ2U=", + mimeType: "image/jpeg" + }); + const claimed = store.claimScreenshotJob("lease-owner", accepted.id); + + assert.ok(claimed.attemptToken); + assert.equal( + store.failScreenshotJob("lease-owner", accepted.id, "stale-token", { + code: "model_timeout", + message: "截图处理超时,请重试。" + }), + null + ); + assert.equal(store.getScreenshotJob("lease-owner", accepted.id).state, "processing"); + + const succeeded = store.succeedScreenshotJob( + "lease-owner", + accepted.id, + claimed.attemptToken, + "card-lease" + ); + assert.equal(succeeded.state, "succeeded"); +}); + +test("an active worker renews its lease so long processing is not reclaimed", () => { + const store = new CardStore(""); + const accepted = store.enqueueScreenshotJob("heartbeat-owner", { + imageBase64: "aGVhcnRiZWF0LWltYWdl", + mimeType: "image/jpeg" + }); + const claimed = store.claimScreenshotJob("heartbeat-owner", accepted.id); + const initialExpiry = Date.parse(claimed.leaseExpiresAt); + const heartbeatAt = new Date(initialExpiry - 60_000); + + assert.equal( + store.renewScreenshotJobLease( + "heartbeat-owner", + accepted.id, + "stale-token", + heartbeatAt + ), + false + ); + assert.equal( + store.renewScreenshotJobLease( + "heartbeat-owner", + accepted.id, + claimed.attemptToken, + heartbeatAt + ), + true + ); + assert.deepEqual( + store.recoverScreenshotJobs(new Date(initialExpiry + 60_000)), + [] + ); +}); + +async function createJob(baseURL, owner, imageBase64) { + return fetch(`${baseURL}/api/screenshot-jobs`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-id": owner + }, + body: JSON.stringify({ imageBase64, mimeType: "image/jpeg" }) + }); +} + +async function waitForJob(baseURL, owner, jobID, expectedState) { + let last; + for (let attempt = 0; attempt < 80; attempt += 1) { + const response = await fetch(`${baseURL}/api/screenshot-jobs/${jobID}`, { + headers: { "x-device-id": owner } + }); + assert.equal(response.status, 200); + last = (await response.json()).job; + if (last.state === expectedState) return last; + await delay(10); + } + assert.fail(`job did not reach ${expectedState}; last state was ${last?.state}`); +} + +async function waitUntil(predicate) { + for (let attempt = 0; attempt < 80; attempt += 1) { + if (predicate()) return; + await delay(10); + } + assert.fail("condition was not reached"); +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function deferred() { + let resolve; + const promise = new Promise((fulfill) => { resolve = fulfill; }); + return { promise, resolve }; +} + +async function withServer(run, options) { + const server = createOmoServer({ + env: { NODE_ENV: "development", OMO_DEMO_MODE: "1" }, + ...options + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const baseURL = `http://127.0.0.1:${server.address().port}`; + try { + await run(baseURL); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + } +} + +function memoryCard(id) { + const now = new Date().toISOString(); + return { + id, + generationMode: "fixture", + coreKnowledge: "合成知识", + hiddenSemantic: "知识", + recallCue: "你还记得吗?", + answer: "知识", + explanation: "合成解释", + sourceTitle: "合成来源", + sourceAccount: "", + sourcePlatform: "unknown", + sourceUrl: "", + sourceStatus: "screenshot_only", + sourceProvider: "tikhub", + sourceReason: "provider_missing", + sourceConfidence: 0, + rarity: "R", + createdAt: now, + masteryStage: "sealed", + nextReviewAt: now, + reviewCount: 0, + successfulRecallCount: 0, + lastAssessment: null, + stepIndex: 0, + attemptIds: [] + }; +} diff --git a/backend/test/searchService.test.js b/backend/test/searchService.test.js new file mode 100644 index 0000000..ebae8f5 --- /dev/null +++ b/backend/test/searchService.test.js @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { readRuntimeConfig } from "../src/runtimeConfig.js"; +import { searchMemoryCards } from "../src/searchService.js"; + +test("semantic search returns only stable unique candidate IDs", async () => { + const response = await searchMemoryCards({ + query: "如何避免认知卸载", + cards: [ + searchCard("owner-card-a", "截图可能触发认知卸载"), + searchCard("owner-card-b", "主动提取可以强化记忆") + ] + }, { + config: qwenConfig(), + fetchImpl: async () => qwenResponse({ + orderedCardIDs: [ + "owner-card-b", + "other-owner-card", + "owner-card-b", + "owner-card-a" + ] + }) + }); + + assert.deepEqual(response, { + orderedCardIDs: ["owner-card-b", "owner-card-a"] + }); +}); + +test("semantic search maps upstream failure and timeout to safe recoverable errors", async () => { + await assert.rejects( + searchMemoryCards({ + query: "认知卸载", + cards: [searchCard("card-a", "认知卸载")] + }, { + config: qwenConfig(), + fetchImpl: async () => new Response("private upstream payload", { status: 500 }) + }), + (error) => error.statusCode === 502 + && error.code === "search_upstream_error" + && !error.message.includes("private upstream payload") + ); + + await assert.rejects( + searchMemoryCards({ + query: "认知卸载", + cards: [searchCard("card-a", "认知卸载")] + }, { + config: qwenConfig(), + fetchImpl: async () => { + throw Object.assign(new Error("private query"), { name: "TimeoutError" }); + } + }), + (error) => error.statusCode === 504 + && error.code === "search_timeout" + && !error.message.includes("private query") + ); +}); + +test("semantic search rejects malformed model output without returning card content", async () => { + await assert.rejects( + searchMemoryCards({ + query: "认知卸载", + cards: [searchCard("card-a", "private card body")] + }, { + config: qwenConfig(), + fetchImpl: async () => qwenResponse({ answer: "private card body" }) + }), + (error) => error.statusCode === 502 + && error.code === "search_invalid_response" + && !error.message.includes("private card body") + ); +}); + +function qwenConfig() { + return readRuntimeConfig({ + NODE_ENV: "development", + QWEN_API: "test-qwen-key", + QWEN_BASE_URL: "https://qwen.example/v1", + QWEN_MODEL: "qwen-test", + QWEN_TIMEOUT_MS: "5000" + }); +} + +function qwenResponse(result) { + return new Response(JSON.stringify({ + choices: [{ message: { content: JSON.stringify(result) } }] + }), { + status: 200, + headers: { "content-type": "application/json" } + }); +} + +function searchCard(id, coreKnowledge) { + return { + id, + coreKnowledge, + hiddenSemantic: "认知卸载", + recallCue: "为什么截图会影响记忆?", + answer: "认知卸载", + explanation: "设备替代了主动编码。", + sourceTitle: "合成测试来源", + sourceAccount: "", + sourcePlatform: "unknown", + sourceUrl: "", + sourceStatus: "screenshot_only", + sourceProvider: "", + sourceConfidence: 0, + rarity: "R", + createdAt: "2026-08-08T00:00:00.000Z", + masteryStage: "sealed", + nextReviewAt: "2026-08-08T00:00:00.000Z", + reviewCount: 0, + successfulRecallCount: 0, + lastAssessment: null + }; +} diff --git a/backend/test/server.test.js b/backend/test/server.test.js index 64ace57..7de46e9 100644 --- a/backend/test/server.test.js +++ b/backend/test/server.test.js @@ -13,6 +13,13 @@ test("health is liveness while explicit local fixture mode can become ready", as const healthBody = await health.json(); const readiness = await fetch(`${baseURL}/api/readiness`); const readinessBody = await readiness.json(); + const imageFlow = await fetch(`${baseURL}/api/sources/image-flow`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ imageBase64: "aGVsbG8=" }) + }); + const imageFlowBody = await imageFlow.json(); + const cards = await fetch(`${baseURL}/api/memory-cards`); assert.equal(health.status, 200); assert.deepEqual(healthBody, { @@ -23,6 +30,28 @@ test("health is liveness while explicit local fixture mode can become ready", as assert.equal(readiness.status, 200); assert.equal(readinessBody.ready, true); assert.equal(readinessBody.checks.model.provider, "fixture"); + assert.equal(imageFlow.status, 200); + assert.equal(imageFlowBody.card.hiddenSemantic, "再次想起"); + assert.equal(imageFlowBody.card.answer, imageFlowBody.card.hiddenSemantic); + assert.equal(cards.headers.get("cache-control"), "no-store"); + }); +}); + +test("public privacy and support pages are served as HTML without requiring API readiness", async () => { + await withServer({ NODE_ENV: "development" }, async (baseURL) => { + const privacy = await fetch(`${baseURL}/privacy`); + const support = await fetch(`${baseURL}/support`); + const linkedPrivacy = await fetch(`${baseURL}/privacy-policy.html`); + const privacyBody = await privacy.text(); + const supportBody = await support.text(); + + assert.equal(privacy.status, 200); + assert.match(privacy.headers.get("content-type"), /^text\/html; charset=utf-8$/); + assert.match(privacyBody, /Omo 隐私政策<\/title>/); + assert.equal(support.status, 200); + assert.match(support.headers.get("content-type"), /^text\/html; charset=utf-8$/); + assert.match(supportBody, /<title>Omo 支持<\/title>/); + assert.equal(linkedPrivacy.status, 200); }); }); @@ -67,6 +96,123 @@ test("development image flow without Qwen returns a stable configuration error", }); }); +test("memory-card search rejects an empty query before calling the model", async () => { + await withServer({ NODE_ENV: "development" }, async (baseURL) => { + const response = await fetch(`${baseURL}/api/memory-cards/search`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-id": "search-owner" + }, + body: JSON.stringify({ query: " " }) + }); + + assert.equal(response.status, 422); + assert.deepEqual(await response.json(), { + code: "search_query_required", + message: "请输入要搜索的知识。" + }); + }); +}); + +test("memory-card search is isolated to the request owner and returns IDs only", async () => { + const store = new CardStore(""); + await store.save("owner-a", searchCard("owner-a-card", "认知卸载")); + await store.save("owner-b", searchCard("owner-b-card", "private other owner content")); + + await withServer({ + NODE_ENV: "development", + QWEN_API: "test-qwen-key", + QWEN_BASE_URL: "https://qwen.example/v1", + QWEN_MODEL: "qwen-test" + }, async (baseURL) => { + const response = await fetch(`${baseURL}/api/memory-cards/search`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-id": "owner-a" + }, + body: JSON.stringify({ query: "怎么避免忘记" }) + }); + const responseText = await response.text(); + + assert.equal(response.status, 200); + assert.deepEqual(JSON.parse(responseText), { + orderedCardIDs: ["owner-a-card"] + }); + assert.equal(responseText.includes("认知卸载"), false); + assert.equal(responseText.includes("private other owner content"), false); + }, { + store, + searchFetchImpl: async () => new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + orderedCardIDs: ["owner-b-card", "owner-a-card"] + }) + } + }] + }), { status: 200 }) + }); +}); + +test("configured PostgreSQL blocks development business routes until migrations are ready", async () => { + const store = { + async readiness() { + return { + ready: false, + driver: "postgres", + durable: true, + reason: "storage_migration_required", + appliedVersions: ["001"], + pendingVersions: ["002"] + }; + }, + async close() {} + }; + + await withServer({ + NODE_ENV: "development", + OMO_DEMO_MODE: "1", + DATABASE_URL: "postgresql://omo:secret@db.example/omo" + }, async (baseURL) => { + const readiness = await fetch(`${baseURL}/api/readiness`); + const readinessText = await readiness.text(); + const cards = await fetch(`${baseURL}/api/memory-cards`); + const cardsBody = await cards.json(); + + assert.equal(readiness.status, 503); + assert.equal(readinessText.includes("secret"), false); + assert.equal(cards.status, 503); + assert.equal(cardsBody.code, "service_not_ready"); + assert.ok(cardsBody.blockers.includes("storage_migration_required")); + }, { store }); +}); + +test("storage readiness failures are sanitized", async () => { + const store = { + async readiness() { + throw new Error("postgresql://user:secret@private-host/omo"); + }, + async close() {} + }; + + await withServer({ + NODE_ENV: "development", + OMO_DEMO_MODE: "1", + DATABASE_URL: "postgresql://omo:secret@db.example/omo" + }, async (baseURL) => { + const response = await fetch(`${baseURL}/api/readiness`); + const text = await response.text(); + const body = JSON.parse(text); + + assert.equal(response.status, 503); + assert.ok(body.blockers.includes("storage_unavailable")); + assert.equal(text.includes("private-host"), false); + assert.equal(text.includes("secret"), false); + }, { store }); +}); + test("unknown server errors are sanitized", async () => { const createCard = async () => { throw new Error("secret model payload"); @@ -92,10 +238,40 @@ test("unknown server errors are sanitized", async () => { }, { createCard }); }); +test("invalid model-card errors expose only the stable sanitized response", async () => { + const createCard = async () => { + throw Object.assign(new Error("视觉模型返回的承重语义无法验证。"), { + statusCode: 502, + code: "model_invalid_response", + expose: true, + invalidCandidate: "private generated payload" + }); + }; + + await withServer({ + NODE_ENV: "development", + OMO_DEMO_MODE: "1" + }, async (baseURL) => { + const response = await fetch(`${baseURL}/api/sources/image-flow`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ imageBase64: "aGVsbG8=" }) + }); + const text = await response.text(); + + assert.equal(response.status, 502); + assert.deepEqual(JSON.parse(text), { + code: "model_invalid_response", + message: "视觉模型返回的承重语义无法验证。" + }); + assert.equal(text.includes("private generated payload"), false); + }, { createCard }); +}); + async function withServer(env, run, options = {}) { const server = createOmoServer({ env, - store: new CardStore(""), + store: options.store || new CardStore(""), ...options }); await new Promise((resolve, reject) => { @@ -113,3 +289,14 @@ async function withServer(env, run, options = {}) { }); } } + +function searchCard(id, coreKnowledge) { + return { + id, + coreKnowledge, + recallCue: "你还记得什么?", + explanation: "合成测试解释", + sourceTitle: "合成测试来源", + createdAt: "2026-08-08T00:00:00.000Z" + }; +} diff --git a/backend/tools/runPostgresTests.mjs b/backend/tools/runPostgresTests.mjs new file mode 100644 index 0000000..cdebae4 --- /dev/null +++ b/backend/tools/runPostgresTests.mjs @@ -0,0 +1,137 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = mkdtempSync(join(tmpdir(), "omo-postgres-")); +const backendDirectory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const dataDirectory = join(root, "data"); +const socketDirectory = join(root, "socket"); +const backupFile = join(root, "omo.backup"); +const port = await availablePort(); +const database = `omo_test_${process.pid}`; +const restoredDatabase = `${database}_restore`; +const connectionURL = `postgresql://postgres@127.0.0.1:${port}/${database}`; +let started = false; + +mkdirSync(socketDirectory); + +try { + run("initdb", [ + "--pgdata", dataDirectory, + "--username", "postgres", + "--auth", "trust", + "--no-locale", + "--encoding", "UTF8" + ]); + run("pg_ctl", [ + "--pgdata", dataDirectory, + "--options", `-h 127.0.0.1 -p ${port} -k ${socketDirectory}`, + "--wait", + "start" + ]); + started = true; + run("createdb", ["--host", "127.0.0.1", "--port", String(port), "--username", "postgres", database]); + + run(process.execPath, ["--test", "test/postgres.integration.test.js"], { + cwd: backendDirectory, + env: { + ...process.env, + TEST_DATABASE_URL: connectionURL, + OMO_ALLOW_TEST_DATABASE_RESET: "1" + } + }); + run(process.execPath, ["src/migrate.js", "--check"], { + cwd: backendDirectory, + env: { + ...process.env, + DATABASE_URL: connectionURL + } + }); + run(process.execPath, ["src/migrate.js"], { + cwd: backendDirectory, + env: { + ...process.env, + DATABASE_URL: connectionURL + } + }); + + run("pg_dump", [ + "--host", "127.0.0.1", + "--port", String(port), + "--username", "postgres", + "--format", "custom", + "--file", backupFile, + database + ]); + run("createdb", [ + "--host", "127.0.0.1", + "--port", String(port), + "--username", "postgres", + restoredDatabase + ]); + run("pg_restore", [ + "--host", "127.0.0.1", + "--port", String(port), + "--username", "postgres", + "--dbname", restoredDatabase, + backupFile + ]); + const restored = capture("psql", [ + "--host", "127.0.0.1", + "--port", String(port), + "--username", "postgres", + "--dbname", restoredDatabase, + "--tuples-only", + "--no-align", + "--command", + "SELECT card->>'answer' FROM omo_memory_cards WHERE owner_id = 'recovery-owner' AND card_id = 'recovery-card'" + ]); + if (restored.trim() !== "合成答案") { + throw new Error("Synthetic backup/restore readback failed."); + } + console.log("PostgreSQL migration, persistence, concurrency and recovery checks passed."); +} finally { + if (started) { + run("pg_ctl", ["--pgdata", dataDirectory, "--mode", "fast", "--wait", "stop"], { + allowFailure: true + }); + } + rmSync(root, { recursive: true, force: true }); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || process.cwd(), + env: options.env || process.env, + encoding: "utf8", + stdio: options.allowFailure ? "pipe" : "inherit" + }); + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`${command} failed with exit code ${result.status}.`); + } +} + +function capture(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"] + }); + if (result.status !== 0) { + throw new Error(`${command} failed with exit code ${result.status}.`); + } + return result.stdout; +} + +function availablePort() { + return new Promise((resolvePort, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close((error) => error ? reject(error) : resolvePort(address.port)); + }); + }); +} diff --git a/config/ExportOptions-TestFlight.plist b/config/ExportOptions-TestFlight.plist new file mode 100644 index 0000000..ebfa4f0 --- /dev/null +++ b/config/ExportOptions-TestFlight.plist @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>destination</key> + <string>export</string> + <key>manageAppVersionAndBuildNumber</key> + <false/> + <key>method</key> + <string>app-store-connect</string> + <key>signingStyle</key> + <string>automatic</string> + <key>stripSwiftSymbols</key> + <true/> + <key>teamID</key> + <string>44589Y6FA6</string> + <key>testFlightInternalTestingOnly</key> + <false/> + <key>uploadSymbols</key> + <true/> +</dict> +</plist> diff --git a/docs/asset-provenance.md b/docs/asset-provenance.md index 38481b3..b6b22bc 100644 --- a/docs/asset-provenance.md +++ b/docs/asset-provenance.md @@ -13,3 +13,10 @@ The iOS client keeps the Omo app icon and the focused motion assets used by the - `RecallFolder` These assets were already part of the Omo project and were restored from `Omo-main.zip`. The frame-atlas player, particles, transitions and feedback are implemented in SwiftUI; no third-party animation package is required. + +The knowledge-library implementation adds two SVGs supplied by the product owner in the Figma handoff for node `884:324` (`Pick The Shell`): + +- `KnowledgeLibraryBack` — SHA-256 `7fcced08d0141faeaa406fe77b26c16f96b41a69e27b8a14b296ff984df265c9` +- `KnowledgeLibraryMicrophone` — SHA-256 `63878e3adc529f8bbdca392c5dcd546b3929c1f84e96b8cf8b9d5b6f4a2a62de` + +They are project-specific design inputs provided for implementation in Omo. The responsive search field, cards, panel and states are native SwiftUI rather than rasterized component screenshots. diff --git a/docs/assets/core-interaction-release/01-empty-home.png b/docs/assets/core-interaction-release/01-empty-home.png new file mode 100644 index 0000000..e3d8e31 Binary files /dev/null and b/docs/assets/core-interaction-release/01-empty-home.png differ diff --git a/docs/assets/core-interaction-release/02-empty-library.png b/docs/assets/core-interaction-release/02-empty-library.png new file mode 100644 index 0000000..3ec2b91 Binary files /dev/null and b/docs/assets/core-interaction-release/02-empty-library.png differ diff --git a/docs/assets/core-interaction-release/03-processing-home.png b/docs/assets/core-interaction-release/03-processing-home.png new file mode 100644 index 0000000..d98d27b Binary files /dev/null and b/docs/assets/core-interaction-release/03-processing-home.png differ diff --git a/docs/assets/core-interaction-release/04-processing-library.png b/docs/assets/core-interaction-release/04-processing-library.png new file mode 100644 index 0000000..de07287 Binary files /dev/null and b/docs/assets/core-interaction-release/04-processing-library.png differ diff --git a/docs/assets/core-interaction-release/05-failed-home.png b/docs/assets/core-interaction-release/05-failed-home.png new file mode 100644 index 0000000..628838b Binary files /dev/null and b/docs/assets/core-interaction-release/05-failed-home.png differ diff --git a/docs/assets/core-interaction-release/06-failed-library.png b/docs/assets/core-interaction-release/06-failed-library.png new file mode 100644 index 0000000..7f17416 Binary files /dev/null and b/docs/assets/core-interaction-release/06-failed-library.png differ diff --git a/docs/assets/core-interaction-release/07-recall-scratch.png b/docs/assets/core-interaction-release/07-recall-scratch.png new file mode 100644 index 0000000..38d8fe1 Binary files /dev/null and b/docs/assets/core-interaction-release/07-recall-scratch.png differ diff --git a/docs/assets/core-interaction-release/08-recall-rating.png b/docs/assets/core-interaction-release/08-recall-rating.png new file mode 100644 index 0000000..210923b Binary files /dev/null and b/docs/assets/core-interaction-release/08-recall-rating.png differ diff --git a/docs/assets/core-interaction-release/09-recall-complete-home.png b/docs/assets/core-interaction-release/09-recall-complete-home.png new file mode 100644 index 0000000..6213ecf Binary files /dev/null and b/docs/assets/core-interaction-release/09-recall-complete-home.png differ diff --git a/docs/assets/core-interaction-release/10-empty-journey-returned-home.png b/docs/assets/core-interaction-release/10-empty-journey-returned-home.png new file mode 100644 index 0000000..9e41748 Binary files /dev/null and b/docs/assets/core-interaction-release/10-empty-journey-returned-home.png differ diff --git a/docs/assets/core-interaction-release/11-processing-journey-returned-home.png b/docs/assets/core-interaction-release/11-processing-journey-returned-home.png new file mode 100644 index 0000000..fb7de6e Binary files /dev/null and b/docs/assets/core-interaction-release/11-processing-journey-returned-home.png differ diff --git a/docs/assets/core-interaction-release/12-staging-upload-accepted.png b/docs/assets/core-interaction-release/12-staging-upload-accepted.png new file mode 100644 index 0000000..58cc9dd Binary files /dev/null and b/docs/assets/core-interaction-release/12-staging-upload-accepted.png differ diff --git a/docs/assets/core-interaction-release/13-staging-recall-complete-library.png b/docs/assets/core-interaction-release/13-staging-recall-complete-library.png new file mode 100644 index 0000000..21af478 Binary files /dev/null and b/docs/assets/core-interaction-release/13-staging-recall-complete-library.png differ diff --git a/docs/assets/knowledge-library/all-cards.png b/docs/assets/knowledge-library/all-cards.png new file mode 100644 index 0000000..6aed2bb Binary files /dev/null and b/docs/assets/knowledge-library/all-cards.png differ diff --git a/docs/assets/knowledge-library/card-detail.png b/docs/assets/knowledge-library/card-detail.png new file mode 100644 index 0000000..c4f1223 Binary files /dev/null and b/docs/assets/knowledge-library/card-detail.png differ diff --git a/docs/assets/knowledge-library/dynamic-type.png b/docs/assets/knowledge-library/dynamic-type.png new file mode 100644 index 0000000..5bb5f1b Binary files /dev/null and b/docs/assets/knowledge-library/dynamic-type.png differ diff --git a/docs/assets/knowledge-library/empty-library.png b/docs/assets/knowledge-library/empty-library.png new file mode 100644 index 0000000..8f3b081 Binary files /dev/null and b/docs/assets/knowledge-library/empty-library.png differ diff --git a/docs/assets/knowledge-library/no-results.png b/docs/assets/knowledge-library/no-results.png new file mode 100644 index 0000000..6dbe91e Binary files /dev/null and b/docs/assets/knowledge-library/no-results.png differ diff --git a/docs/assets/knowledge-library/search-failure.png b/docs/assets/knowledge-library/search-failure.png new file mode 100644 index 0000000..0d71f80 Binary files /dev/null and b/docs/assets/knowledge-library/search-failure.png differ diff --git a/docs/assets/knowledge-library/search-results.png b/docs/assets/knowledge-library/search-results.png new file mode 100644 index 0000000..cd8b240 Binary files /dev/null and b/docs/assets/knowledge-library/search-results.png differ diff --git a/docs/assets/knowledge-library/small-device.png b/docs/assets/knowledge-library/small-device.png new file mode 100644 index 0000000..5fb8514 Binary files /dev/null and b/docs/assets/knowledge-library/small-device.png differ diff --git a/docs/assets/knowledge-library/text-input-keyboard.png b/docs/assets/knowledge-library/text-input-keyboard.png new file mode 100644 index 0000000..9cfdfcf Binary files /dev/null and b/docs/assets/knowledge-library/text-input-keyboard.png differ diff --git a/docs/assets/knowledge-library/upload-sheet.png b/docs/assets/knowledge-library/upload-sheet.png new file mode 100644 index 0000000..780c0c6 Binary files /dev/null and b/docs/assets/knowledge-library/upload-sheet.png differ diff --git a/docs/assets/knowledge-library/voice-search.png b/docs/assets/knowledge-library/voice-search.png new file mode 100644 index 0000000..33d2bb0 Binary files /dev/null and b/docs/assets/knowledge-library/voice-search.png differ diff --git a/docs/core-interaction-release-validation.md b/docs/core-interaction-release-validation.md new file mode 100644 index 0000000..358b6af --- /dev/null +++ b/docs/core-interaction-release-validation.md @@ -0,0 +1,56 @@ +# Omo 核心交互 Release 验收证据 + +- 日期:2026-08-09 +- 分支:`codex/omo-independent-app` +- App:Omo `1.0 (3)`,Bundle ID `com.maxhan.omo` +- TestFlight 目标:独立 Omo 外部测试组 `Omo` +- staging:`https://omo-api-staging-staging.up.railway.app` + +## 自动化结果 + +- iPhone 16 Pro / iOS 18.5 Simulator。 +- Debug 与 Release 优化配置分别运行 52 项 XCTest / UI Test:每套 52 通过、0 失败、0 跳过。其中 44 项为逻辑、持久化与 API 测试,8 项为真实 UI 操作流程。 +- 测试命令只在测试构建中启用 `ENABLE_TESTABILITY=YES` 和 `OMO_TESTING`;普通 Release Archive 不包含 Fixture、Mock 或本地地址回退。 +- 普通 Release 已另行构建并扫描:不包含 `OmoLibraryFixture`、`OmoScreenshotJobFixture`、`OmoAssessmentFixture`、`OMO_STAGING_UI`、`localhost` 或 `127.0.0.1`,仍为 `com.maxhan.omo` / build 3 / Omo staging。 +- 状态测试覆盖 79% 不揭示、80% 揭示、自评取消区、三个正式节点、评估失败重试与牌组推进。 +- UI Test 覆盖空库、处理中、失败、知识库、刮开和揭示后自评,并逐个断言菜单、Profile、Settings、知识库与上传入口可达且可以返回首页。 +- 完整单卡旅程使用真实滑动手势完成刮开和自评,最后断言卡层退出、知识库和上传入口恢复可用,避免“控件存在但用户仍被困住”。 + +## Release 状态截图 + +以下截图来自上述成功的 Release UI Test。它们证明布局、入口层级和核心状态可达;Fixture 只用于确定性 UI 验收,不替代真实 staging 证据。 + +| 状态 | 截图 | +|---|---| +| 空库首页 | [01-empty-home.png](assets/core-interaction-release/01-empty-home.png) | +| 空知识库 | [02-empty-library.png](assets/core-interaction-release/02-empty-library.png) | +| 处理中首页 | [03-processing-home.png](assets/core-interaction-release/03-processing-home.png) | +| 处理中知识库 | [04-processing-library.png](assets/core-interaction-release/04-processing-library.png) | +| 失败首页 | [05-failed-home.png](assets/core-interaction-release/05-failed-home.png) | +| 失败知识库 | [06-failed-library.png](assets/core-interaction-release/06-failed-library.png) | +| 待刮开的复习卡 | [07-recall-scratch.png](assets/core-interaction-release/07-recall-scratch.png) | +| 揭示后的三档自评 | [08-recall-rating.png](assets/core-interaction-release/08-recall-rating.png) | +| 完成最后一张后回到可操作首页 | [09-recall-complete-home.png](assets/core-interaction-release/09-recall-complete-home.png) | +| 空库用户遍历页面并返回首页 | [10-empty-journey-returned-home.png](assets/core-interaction-release/10-empty-journey-returned-home.png) | +| 处理中取消另一次上传且任务仍在 | [11-processing-journey-returned-home.png](assets/core-interaction-release/11-processing-journey-returned-home.png) | + +## 真实 staging 证据 + +- 提供的真实截图通过 App 的系统照片选择器上传到真实 staging;同一条自动化旅程验证了首次 AI 许可、异步任务接收、杀 App、重新启动和任务恢复。 +- 重启后任务生成 canonical 卡片,自动化继续点击 IP、真实刮开承重语义、拖动自评,并从首页进入知识库确认卡片仍存在。 +- [12-staging-upload-accepted.png](assets/core-interaction-release/12-staging-upload-accepted.png) 记录任务已接收且其他入口仍可用;[13-staging-recall-complete-library.png](assets/core-interaction-release/13-staging-recall-complete-library.png) 记录完整复习后卡片仍在知识库。 +- 另一次 App 内真实上传遇到 Qwen 60 秒超时,任务进入明确 `failed / retryable`,而不是页面永久卡死。 +- 点击失败任务重试后,同一个 task ID 回到处理中;菜单、知识库、上传入口保持可用。 +- 处理中杀 App 并重新启动后任务仍存在;再次超时后重新显示明确重试状态。 +- Railway readiness 为 `ready=true`,Postgres migration `001` / `002` / `003` 均已应用,pending 为空。 + +## Release 与 TestFlight + +- build 3 Archive 包内 Bundle ID 为 `com.maxhan.omo`,版本为 `1.0 (3)`,API 地址只指向 Omo staging。 +- `testFlightInternalTestingOnly=false` 已写入导出配置并由自动化门禁保护,构建可送交 Beta App Review;该配置不会发布 App Store 正式版。 +- 外部测试组 `Omo` 使用独立 Omo App 记录,公开链接为 <https://testflight.apple.com/join/rZ8pBE7e>;从未使用 Recallo App 或测试组。 + +## 尚需人工确认 + +- Apple Beta App Review 批准外部构建后,实体手机仍应按 [[docs/frontend-interaction-prd#15-前端验收清单]] 做一次公开链接安装复核;这属于分发确认,不替代本页已经通过的 Simulator 与真实 staging 用户旅程。 +- Share Extension 的首次 AI 授权跳转仍是 [[docs/frontend-interaction-prd#17-请产品重点复核的边界]] 中的待确认产品决定;本轮没有以错误的设备身份或假成功方式实现。 diff --git a/docs/decision-log.md b/docs/decision-log.md index 10881ae..ba0e350 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -163,11 +163,45 @@ Omo 的开放 Issue 缺少统一标签、Milestone、验收和依赖;部分 Is 接入并验证耐久存储 Adapter 后,更新 storage readiness 的实现与部署证据;若 TikHub 不再是生产必需依赖,必须先明确新的来源证据合同,再调整其 required 状态。 +## 2026-07-29:PostgreSQL 使用显式顺序 migration 与过渡 owner 合同 + +- 状态:accepted +- 决策者:Project owner / Codex +- 相关计划:`plans/feat-postgres-persistence.md` + +### 背景 + +生产 fail-closed 已明确拒绝 JSON Store,但直接在服务启动时自动建表、静默导入本地 JSON 或提前把 `X-Device-Id` 当账号,会混淆 migration 授权、数据来源和 #19 的身份边界。 + +### 决定 + +- `DATABASE_URL` 显式选择 PostgreSQL;配置存在但错误时不回退 JSON。 +- migration 只通过显式 CLI 顺序执行,记录 checksum,并用 advisory lock 串行化;服务与 readiness 不自动修改 Schema。 +- 当前 owner 是不透明的 `device` 过渡键,不是认证;#19 再定义账号、会话和旧 owner 迁移。 +- 重复卡片保留首次 canonical 状态,assessment 使用数据库唯一 attempt 与版本 fencing。 +- JSON 导入只能显式 dry-run/确认授权;生产恢复使用新数据库验证后切换,不提供破坏性 down migration。 + +### 理由 + +显式 migration 把生产副作用留在人工授权的发布步骤;checksum 和锁使多人/多实例执行可复核。过渡 owner 让存储先落地,同时避免把可伪造请求头误写成已完成账号体系。 + +### 后果 + +- 后端 Store 调用统一为可等待合同,非生产 JSON 仍兼容。 +- PostgreSQL readiness 同时检查连接、版本和 checksum。 +- #19 必须在现有 owner 合同上增加认证映射,不能把 `X-Device-Id` 直接升级为可信账号。 +- #20 负责真实 Railway migration、连接池容量、备份权限、发布停止条件和部署 readback。 + +### 验证或复审条件 + +当 #19 冻结认证 subject 或 #20 确认 Railway/Postgres 网络与备份能力时,复审 owner 映射、TLS、连接池和迁移发布步骤;任何生产数据操作仍需新的 manual 授权。 + ## 相关文档 - [[AGENTS]] - [[docs/index]] - [[docs/issue-management-workflow]] +- [[docs/postgres-persistence]] - [[docs/documentation-guide]] - [[docs/ios-api-data-contract-zh]] - [[docs/quality-baseline]] diff --git a/docs/frontend-interaction-prd.md b/docs/frontend-interaction-prd.md new file mode 100644 index 0000000..7df989b --- /dev/null +++ b/docs/frontend-interaction-prd.md @@ -0,0 +1,527 @@ +# Omo MVP 前端交互 PRD + +- 状态:产品验收基线,待产品逐项复核 +- 范围:iOS MVP 前端交互 +- 目的:统一产品、设计、开发与测试对“用户在每个状态下看到什么、能做什么、失败后如何继续”的理解 +- 说明:本文描述目标交互,不代表当前 TestFlight 构建已经全部满足 + +## 1. 产品主线 + +Omo 只围绕一条主线工作: + +```text +用户保存一张自己认为有价值的截图 +→ Omo 自动处理并生成一张知识卡 +→ 用户通过 IP 抽取最多十张待复习卡 +→ 先尝试回忆,再刮开承重语义 +→ 用户滑动自评 +→ Omo 安排下一次复习 +``` + +首页只有三个核心入口: + +- IP:开始抽卡与主动回忆。 +- 收藏夹:进入知识库,浏览所有完整知识卡和处理状态。 +- 加号:从系统相册选择一张新截图。 + +侧边菜单只承载 Profile 和 Settings,不与三个核心入口竞争注意力。 + +## 2. 全局硬规则 + +以下规则适用于所有页面和状态,也是测试的第一优先级: + +1. 除系统照片选择器、系统权限弹窗和必要确认弹窗外,不允许出现整页交互锁死。 +2. 处理一张截图时,菜单、收藏夹和加号仍可使用;若已有可复习卡,IP 也仍可使用。 +3. 任何超过合理等待时间的操作必须结束为成功或明确失败,不得无限显示“正在处理”。 +4. App 被切到后台、系统中断或被用户杀掉后,再次打开必须恢复真实状态,不能仅清掉界面提示。 +5. 每个失败状态至少提供一个低成本恢复动作:重试、重新上传、清除搜索或返回。 +6. 页面上看起来像按钮的元素必须能点击;不能点击的元素不得使用按钮动效、箭头引导或 VoiceOver 按钮语义。 +7. 用户已经生成的知识卡不能因页面切换、请求失败或重新启动而消失。 +8. Fixture、Mock 和静态截图只用于开发;TestFlight 验收必须连接独立 Omo staging,并使用最终待测构建。 +9. 揭示前不得在可见文字、无障碍信息、详情入口或状态恢复中泄露被遮住的答案。 +10. 稀有度只负责视觉氛围,不增加确认步骤,也不影响复习调度。 + +## 3. 用户故事 + +1. 作为第一次使用 Omo 的用户,我希望首页直接引导我上传自己的截图,从而尽快体验第一张属于我的知识卡。 +2. 作为用户,我希望取消系统照片选择后原样回到首页,不产生空任务或错误提示。 +3. 作为用户,我希望截图开始处理后仍可进入知识库或继续上传,而不是被锁在等待状态。 +4. 作为用户,我希望处理时间较长时能确认任务仍然存在,而不是猜测 App 是否卡死。 +5. 作为用户,我希望处理失败时得到明确反馈和重试入口。 +6. 作为用户,我希望关闭并重新打开 App 后仍能看到正在处理、成功或失败的真实结果。 +7. 作为用户,我希望第一张卡生成成功后,首页明确把注意力转向可点击的 IP。 +8. 作为用户,我希望收藏夹在空库、处理中和有卡时都能打开。 +9. 作为用户,我希望知识库直接展示完整知识,不需要再次刮开。 +10. 作为用户,我希望能从知识库继续上传截图。 +11. 作为用户,我希望通过文字或语音描述找回模糊记得的知识卡。 +12. 作为用户,我希望点击 IP 后一次抽取最多十张可复习卡;不足十张就复习现有全部卡片。 +13. 作为用户,我希望抽卡动画结束后直接看到第一张未揭示的卡,而不是再做一次确认。 +14. 作为用户,我希望在当前卡片下隐约看到下一张卡的稀有度光效,增加期待感。 +15. 作为用户,我希望只刮开真正需要回忆的关键词或承重语义,不必刮整张卡。 +16. 作为用户,我希望局部刮开时只看到被刮到的区域。 +17. 作为用户,我希望刮开达到 80% 后自动完全揭示,并出现自评滑条。 +18. 作为用户,我希望揭示后的答案通过字重和颜色与上下文形成明确层级。 +19. 作为用户,我希望拖动自评时通过节点震动、轨道颜色和位置理解当前选择。 +20. 作为用户,我希望拖回最左侧松手可以取消本次自评,卡片不切换。 +21. 作为用户,我希望在三个自评节点松手后立即确认并自动进入下一张卡。 +22. 作为用户,我希望自评提交失败时留在当前已揭示卡,并能重试,避免误以为结果已保存。 +23. 作为用户,我希望在回顾过程中仍可打开收藏夹或上传截图。 +24. 作为用户,我希望通过卡片的次级入口查看更完整上下文,但它不是完成回顾的必经步骤。 +25. 作为用户,我希望问题通知在 App 外戳中“我到底还记不记得”,点击后直接进入对应的未揭示卡。 +26. 作为用户,我希望通知卡完成后回到原来的首页状态,而不是进入另一套做题页面。 +27. 作为使用 VoiceOver、较大字号或减少动态效果的用户,我希望所有核心动作仍完整可用。 + +## 4. 首页固定结构 + +首页是抽卡和复习发生的主场景,不跳转到独立“做题页”。固定结构包括: + +- 左上:侧边菜单按钮。 +- 中央主要区域:IP;有可复习卡时是抽卡入口。 +- 下方主体:收藏夹视觉;点击进入知识库。 +- 右下:加号;点击打开 iOS 系统照片选择器。 +- 卡片层:抽卡后叠加在首页主体上,不移除收藏夹和加号。 + +具体 IP 形象和动画不由本文定义;但 IP 的可点击与不可点击状态必须能被用户分辨。 + +### 4.1 首页入口可用性矩阵 + +| 首页状态 | 菜单 | IP | 收藏夹 | 加号 | 当前卡交互 | +|---|---:|---:|---:|---:|---:| +| 首次空库 | 可用 | 不抽卡 | 可用 | 可用 | 无 | +| 只有截图处理中 | 可用 | 无可复习卡时不抽卡 | 可用 | 可用 | 无 | +| 处理中且已有可复习卡 | 可用 | 可用 | 可用 | 可用 | 无 | +| 有可复习卡、首页空闲 | 可用 | 可用 | 可用 | 可用 | 无 | +| 抽卡动画 | 可用 | 本次抽取中不重复触发 | 可用 | 可用 | 动画结束后可用 | +| 刮开中 | 可用 | 不重复触发 | 可用 | 可用 | 可用 | +| 等待自评 | 可用 | 不重复触发 | 可用 | 可用 | 可用 | +| 自评提交中 | 可用 | 不重复触发 | 可用 | 可用 | 防止重复提交 | +| 自评失败 | 可用 | 不重复触发 | 可用 | 可用 | 重试可用 | +| 通知卡叠加 | 可用 | 不重复触发 | 可用 | 可用 | 通知卡可用 | +| 首页数据加载失败 | 可用 | 仅在已有本地可用卡时可用 | 可用 | 可用 | 重试加载可用 | + +空库时点击收藏夹进入“空知识库”,而不是没有响应。空库 IP 不启动抽卡,也不显示指向 IP 的抽卡箭头。 + +## 5. 首次使用与截图上传 + +### 5.1 首次空库 + +用户第一次进入且没有知识卡或处理任务时: + +- 首页只突出“上传第一张知识截屏”。 +- 箭头指向加号。 +- 不预置示例卡或他人的截图。 +- 收藏夹可以点击,进入空知识库。 +- IP 不承担抽卡动作,也不伪装成可点击入口。 + +### 5.2 打开照片选择器 + +1. 用户点击加号。 +2. App 打开 iOS 系统照片选择器,只选择一张图片。 +3. 用户取消:关闭选择器,首页状态不变,不创建任务。 +4. 用户确认图片:App读取并压缩图片。 +5. 图片读取失败:回到原页面,显示简短错误;加号保持可用。 + +### 5.3 首次 AI 处理许可 + +首次把截图发送给 AI 前显示系统风格确认弹窗: + +- 取消:不上传、不创建处理任务,回到原页面。 +- 同意并生成:记住许可,开始当前截图处理。 +- 撤回许可后,下次上传重新询问。 + +许可弹窗是上传前唯一允许暂时阻断页面操作的产品弹窗。 + +### 5.4 截图处理开始 + +用户同意后: + +1. 立即回到首页。 +2. 创建一条可恢复的截图处理记录,而不是只保存一个内存中的转圈状态。 +3. 首页以轻量方式显示“正在整理第一张知识卡”或对应处理数量。 +4. 收藏夹内能看到该截图对应的处理中占位项。 +5. 菜单、收藏夹和加号保持可用;已有可复习卡时 IP 保持可用。 +6. 自动执行来源识别和溯源,不额外询问用户是否溯源。 + +实现状态(2026-08-08):App 内首页与知识库入口已共用同一个上传状态协调器;每张截图使用独立持久任务,其他截图处理中不会禁用菜单、收藏夹、加号或已有卡片的抽取入口。 + +同一张截图的重复提交不得产生互相冲突的重复卡;界面应回到同一条 canonical 处理结果。 + +### 5.5 处理成功 + +处理成功后: + +- 处理中占位项替换为完整知识卡。 +- 卡片立即出现在知识库。 +- 该卡满足召回合同且到期时,进入可抽取卡池;MVP 不强制等待 24 小时。 +- 如果这是第一张可抽取卡,首页引导从加号切换到 IP。 +- IP 变成可点击抽卡入口。 +- 不强制自动开始抽卡,决定权仍在用户。 + +来源自动匹配失败不等于整张卡失败:只依据截图生成卡,来源状态如实标记,稀有度保持最低等级 R。 + +### 5.6 处理失败与超时 + +模型、网络、服务配置或数据格式失败时: + +- 结束“处理中”,不得无限等待。 +- 首页给出简短失败反馈,但不覆盖所有入口。 +- 知识库中的处理项显示失败状态和“重试”。 +- 重试复用同一个处理记录,不额外制造重复卡。 +- 用户也可以通过加号重新选择图片。 +- 不用 Fixture、静态卡或假成功替代真实失败。 + +MVP 不要求用户纠正 AI 内容,也不要求失败后手动补来源链接。 + +### 5.7 进入后台与重新启动 + +- App 进入后台时,前端不得把任务标记为完成或失败。 +- 用户杀掉 App 再打开,首页先加载卡片和未完成任务。 +- 后端仍在处理:恢复“处理中”占位,其他入口可用。 +- 后端已成功:显示新卡并启用 IP。 +- 后端已失败或任务已超时:显示失败和重试。 +- 找不到任务记录:明确显示任务中断,不允许静默消失。 + +“杀掉 App 后处理中提示消失、卡片也不存在”属于验收失败。 + +实现状态(2026-08-08):iOS 启动会合并本地与服务端任务并恢复轮询;服务端已经成功时同时刷新卡片并清除本地重试图片,失败任务继续提供同 ID 重试。最终 TestFlight 真机结果仍以本 PRD 验收清单为准。 + +### 5.8 Share Extension 外部入口 + +截图后的系统分享是 Omo 预期的主要日常保存入口;App 内加号同时承担首次体验和补充入口。 + +- 用户在系统截图或照片分享面板选择 Omo。 +- Share Extension 不要求填写标题、分类、关键词或其他表单。 +- 已完成 AI 处理授权时,扩展先把截图可靠交给 Omo,再显示简短成功反馈并允许关闭;成功只表示任务已被接收,不冒充知识卡已生成。 +- 首次尚未完成 AI 处理授权时,扩展不得静默上传;应引导打开 Omo 完成一次授权,再继续处理该截图。 +- 任务接收后,App 首页和知识库显示与 App 内上传相同的处理中、成功或失败状态。 +- 扩展提交失败时保留明确重试或打开 Omo 的动作,不能显示假成功。 +- 重复分享同一张截图遵循同一个 canonical 处理结果。 + +MVP 不把“敲击手机背面”等系统手势作为必须交付能力;它可以在后续作为触发 Share Extension 或 Shortcut 的快捷方式研究。 + +## 6. 有卡首页与抽卡 + +### 6.1 IP 可点击条件 + +只有存在至少一张满足以下条件的卡时,IP 才启动抽卡: + +- 已完成生成并持久化; +- 有合法的承重语义; +- 当前已到复习时间; +- 未处于删除或失败状态。 + +存在知识卡但暂时没有待复习卡时,收藏夹和加号仍可用;IP 不伪装成可抽取状态。 + +### 6.2 默认十连抽 + +- MVP 只有默认十连抽。 +- 每轮从当前可复习卡池取最多 10 张。 +- 少于 10 张时抽取剩余全部卡片。 +- 点击 IP 的瞬间冻结本轮卡片和顺序;本轮中新生成或重新调度的卡不插入中途。 +- MVP 不提供单抽、分类抽取、关键词选择或文字保底说明。 + +### 6.3 抽卡呈现 + +1. 用户点击 IP。 +2. 播放抽取反馈与卡堆出现动画。 +3. 动画结束直接显示第一张未揭示卡。 +4. 卡堆最多呈现四层视觉深度,不等于四张或四种稀有度。 +5. 下一张卡可以从被压住的边缘隐约发光,提前暗示其稀有度。 +6. 当前卡稀有度可以随卡出现而显示;它不是需要点击查看的步骤。 + +Reduce Motion 开启时缩短或移除装饰位移,但必须直接进入相同的第一张卡状态。 + +## 7. 回忆卡与刮刮乐 + +### 7.1 卡片内容 + +卡片显示一条连续知识表达: + +- 普通上下文直接显示。 +- 只有承重语义被刮层遮住。 +- 用户先根据问题和可见上下文尝试回忆。 +- 原截图不直接作为卡面答案;截图只是 AI 恢复来源和生成知识的锚点。 + +### 7.2 局部刮开 + +- 手指只擦除经过区域的遮罩。 +- 刮开一点,只显示对应区域的关键词文字。 +- 覆盖率低于 80% 时不显示自评滑条。 +- 刮开进度不因短暂触碰、页面重绘或旋转而意外归零。 +- 刮层只接收卡片语义区域内的手势,不抢占收藏夹、加号或系统返回手势。 + +### 7.3 达到揭示阈值 + +达到 80% 后: + +1. 自动归一为 100% 完全揭示。 +2. 触发一次轻震动。 +3. 显示完整承重语义。 +4. 出现自评滑条。 +5. 已揭示文字使用更强字重和强调色;普通上下文保持较弱权重。 + +不提供额外“直接揭晓”按钮,也不要求再次点击确认揭示。 + +### 7.4 完整上下文 + +- 完整上下文是卡片上的次级入口,不是必经步骤。 +- 主动回忆卡在揭示前不能通过详情绕过答案遮挡。 +- 揭示后可展开详情,查看完整知识、简短解释、来源标题、账号、平台、链接和真实来源状态。 +- 关闭详情回到同一张卡,保留刮开和自评状态。 +- 知识库内打开详情时直接显示完整内容,不使用刮层。 + +## 8. 滑动自评 + +### 8.1 结构 + +滑条从左到右包含四个释放位置: + +1. 最左取消区; +2. 忘了; +3. 没记清; +4. 记住了。 + +滑条只在答案完全揭示后出现。 + +### 8.2 拖动反馈 + +- 用户从最左侧拖动箭头。 +- 经过每个正式节点时触发一次轻震动。 +- 轨道是预先定义好的完整渐变;拖动位置只决定填充到渐变的哪个位置。 +- 已填充部分显示该位置对应的渐变颜色,不重新计算为“起点色到当前色”的双色渐变。 +- 箭头保持固定颜色,不跟随轨道渐变。 +- 节点、描边或投影可随当前选择变化,但不能比卡片答案更抢注意力。 + +### 8.3 松手结果 + +- 在最左取消区松手:取消自评,箭头回到起点,卡片保持完全揭示,不切换。 +- 在“忘了”节点松手:提交 forgot。 +- 在“没记清”节点松手:提交 fuzzy。 +- 在“记住了”节点松手:提交 remembered。 +- 在两个节点之间松手:吸附到最近的有效节点后提交;取消区仍只执行取消。 +- 一次松手只提交一次,不增加二次确认按钮。 + +### 8.4 提交成功与失败 + +成功: + +- 保存自评结果和下一次复习时间。 +- 当前卡退出,下一张卡进入并恢复完全遮挡。 +- 最后一张成功后结束本轮,回到首页空闲状态。 + +失败: + +- 留在当前已揭示卡。 +- 保留刚才选择的位置和明确错误反馈。 +- 提供重试,不自动跳到下一张。 +- 防止连续松手产生重复 assessment。 + +## 9. 回顾过程中的离开与返回 + +- 首页不增加专门的中途关闭按钮。 +- 打开系统照片选择器、AI 许可弹窗或卡片详情属于临时叠层;关闭后回到同一张卡并保留当前状态。 +- 进入知识库、Profile 或 Settings 属于离开当前轮次;已经成功提交的自评保留,未提交卡片的局部刮开不保存。 +- 返回首页后不恢复半轮,用户再次点击 IP 开始一轮新的最多十张牌组。 +- App 被系统终止时同样不恢复半轮,但不得丢失已成功提交的自评。 + +## 10. 知识库 + +知识库是次级页面,用于直接浏览和主动找回,不是复习页面。 + +### 10.1 进入与退出 + +- 首页点击收藏夹进入知识库。 +- 空库也能进入,并显示上传入口。 +- 返回按钮回到首页。 +- 知识库加号打开相同的系统照片选择流程。 + +### 10.2 卡片与处理项 + +- 已完成卡片直接显示完整知识,不刮开、不提问。 +- 点击卡片打开完整上下文。 +- 正在处理的截图显示占位项和真实状态,不混入搜索结果。 +- 失败的处理项显示重试入口。 +- 页面放不下时根据实际卡片高度分页,不按文字字数硬编码。 + +### 10.3 文字搜索 + +- 输入文字并点击搜索或键盘 Search 提交。 +- 空白输入不发请求。 +- 新搜索取消旧搜索;旧结果不能覆盖新查询。 +- 清空输入立即恢复全部卡片并回到第一页。 +- 加载、结果、无结果和失败必须是四个不同状态。 +- 搜索失败保留查询,并提供重试或清除。 + +### 10.4 语音搜索 + +- 点击麦克风后按系统流程请求权限。 +- 授权后显示监听状态和实时转写。 +- 停止语音后把最终文字作为同一搜索框的查询提交。 +- 拒绝权限时说明原因并提供进入 Settings 的恢复入口。 +- 离开页面、开始文字搜索或系统中断时停止录音。 + +知识库更完整的分页、搜索和语音规则见 [[docs/knowledge-library-prd]]。 + +## 11. 问题通知 + +通知用于模拟未来“想用却想不起来”的痛点,不直接把答案塞给用户。 + +示例: + +```text +通知:为什么保存截图,有时反而会让你更难记住内容? + +点击后卡片: +截图可能削弱记忆,因为它会触发 █████:用户认为设备已经替自己保存。 +``` + +交互规则: + +- 通知问题来自某一张具体卡的 recall cue。 +- 通知标题、正文和 payload 不包含承重语义或答案。 +- 点击通知后打开首页,并在当前首页之上叠加对应未揭示卡。 +- 不跳转到独立通知页,也不重新抽取十张卡。 +- 通知卡使用与普通卡相同的刮开和自评逻辑。 +- 完成后关闭通知卡,回到通知前的首页状态。 +- 卡片已删除、不可召回或加载失败时,显示可理解反馈并回到首页,不能展示错误卡或空白层。 + +## 12. 侧边菜单、Profile 与 Settings + +- 点击左上菜单打开侧边抽屉;点击遮罩关闭。 +- 抽屉只包含 Profile 和 Settings。 +- Profile、Settings 都能明确返回首页。 +- AI 处理许可可以在 Settings 撤回。 +- 隐私说明和联系支持保持可访问。 +- 打开菜单时底层页面不可误触;关闭菜单后恢复原状态。 + +## 13. 反馈、动效与可访问性 + +### 13.1 触觉反馈 + +- 点击 IP 开始抽取:一次选择反馈。 +- 刮开达到 80%:一次轻震动。 +- 自评经过有效节点:每个节点一次轻震动。 +- 自评提交成功:轻量完成反馈。 +- 失败不使用成功震动。 + +### 13.2 触控 + +- 菜单、IP、收藏夹、加号、返回、详情、搜索、麦克风、重试和滑条有效区域至少 44 × 44 pt。 +- 装饰图片不拦截其上方或附近按钮的点击。 +- 卡片手势不能覆盖系统边缘返回或底层持久入口。 + +### 13.3 Dynamic Type、VoiceOver 与 Reduce Motion + +- 大字号允许页面纵向滚动,不截断核心知识和主要动作。 +- VoiceOver 在揭示前只能读到问题、可见上下文和“答案仍被遮挡”,不能读出承重语义。 +- 揭示后再把完整句子加入无障碍阅读。 +- IP 只有可抽卡时才具有按钮语义。 +- Reduce Motion 关闭装饰位移,但不删除抽卡完成、揭示完成、自评节点和换卡的因果反馈。 + +## 14. 错误与恢复矩阵 + +| 场景 | 用户看到 | 用户仍可做 | 不允许发生 | +|---|---|---|---| +| 首页卡片加载失败 | 简短错误与重试 | 菜单、收藏夹、上传、重试 | 整页空白或永久转圈 | +| 图片读取失败 | 读取失败提示 | 重新选择、返回 | 创建幽灵任务 | +| 截图处理中 | 真实处理中状态 | 浏览知识库、继续上传、复习旧卡 | 禁用全部入口 | +| 生成超时 | 失败状态与重试 | 重试、重新上传、浏览旧卡 | 无限显示处理中 | +| App 重启时任务未完成 | 恢复处理中项 | 使用其他入口 | 任务静默消失 | +| 来源匹配失败 | 截图生成的 R 卡与真实来源状态 | 正常浏览和复习 | 伪造原文或阻止生成 | +| 自评提交失败 | 当前已揭示卡和重试 | 重试、浏览/上传 | 自动换卡 | +| 搜索无结果 | 明确无结果 | 改查、清除、查看全部 | 显示为系统错误 | +| 搜索失败 | 错误与重试 | 重试、清除、返回 | 保留伪旧结果 | +| 通知目标卡不存在 | 轻提示后回首页 | 使用首页全部有效入口 | 空白遮罩或崩溃 | + +## 15. 前端验收清单 + +任何构建只有完成以下实际操作,才能声明“前端交互通过”。 + +### 15.1 验收环境 + +- 使用准备提交给测试员的同一个 Archive/IPA,而不是另一个 Debug Bundle。 +- 至少覆盖一个真实 iPhone;Simulator 作为补充,不替代真机。 +- Release 连接独立 Omo staging;验收记录写明 App ID、Bundle ID、版本和 build number。 +- 正常路径使用真实服务;Mock 状态必须单独标记。 + +### 15.2 首页与上传 + +- [ ] 空库首页:菜单、收藏夹、加号逐个实际点击。 +- [ ] IP 空库状态不会误触发抽卡,也不会表现成坏掉的按钮。 +- [ ] 照片选择取消后首页无副作用。 +- [ ] 首次 AI 许可取消和同意分别验证。 +- [ ] 上传开始后逐个点击菜单、收藏夹和加号。 +- [ ] 处理旧卡期间点击 IP,确认不受新任务阻塞。 +- [ ] 真实截图生成成功并在首页、知识库和重新启动后存在。 +- [ ] 人为制造慢请求,确认界面不锁死。 +- [ ] 人为制造超时/断网,确认结束为失败并可重试。 +- [ ] 处理中切后台再返回,状态正确。 +- [ ] 处理中杀掉 App 再打开,任务恢复为处理中、成功或失败之一,不能消失。 +- [ ] 从系统分享面板提交截图,确认任务进入与 App 内上传相同的状态链。 +- [ ] Share Extension 首次未授权、接收成功、接收失败和重复分享分别验证。 + +### 15.3 抽卡、刮开与自评 + +- [ ] 1 张、少于 10 张和至少 10 张三种牌组分别验证。 +- [ ] 点击 IP 只触发一次抽取。 +- [ ] 四层牌堆和下一张稀有度光效不承担额外交互。 +- [ ] 79% 不显示自评;80% 自动完整揭示。 +- [ ] 揭示前通过视觉和 VoiceOver 都读不到答案。 +- [ ] 揭示后文字字重和颜色层级正确。 +- [ ] 滑条完整渐变、固定色箭头和三个节点反馈正确。 +- [ ] 最左取消不切卡;forgot、fuzzy、remembered 各提交一次。 +- [ ] 自评失败留在当前卡并可重试。 +- [ ] 下一张重新完全遮挡;最后一张后返回首页。 +- [ ] 回顾中收藏夹和加号逐个实际点击。 + +### 15.4 知识库与通知 + +- [ ] 空库、处理中、失败、单卡、多页完整卡分别验证。 +- [ ] 知识库卡片无需刮开,详情完整。 +- [ ] 文字搜索:加载、结果、无结果、失败、清除、竞态逐项验证。 +- [ ] 语音搜索:允许、拒绝、监听、停止和系统中断逐项验证。 +- [ ] 点击真实问题通知后,在首页叠加正确的未揭示卡。 +- [ ] 通知卡不存在时可恢复,不出现空白遮罩。 + +### 15.5 证据要求 + +- 每个关键状态保留截图或录屏,并记录进入路径。 +- 点击可用性必须通过实际点击验证,不能只看截图或读代码。 +- 自动测试说明覆盖了哪条外部行为;Store Stub 成功不能替代真实上传。 +- 验收报告必须单列“未验证项”,不能把历史构建或旧 Bundle 的结果沿用到新构建。 + +## 16. MVP 暂不包含 + +- 单抽与十连抽切换。 +- 分类抽、关键词选池和领域筛选。 +- 用户可见的文字保底机制。 +- 用户纠正 AI 内容或生成答案。 +- 溯源失败后手动补链接与升格。 +- 主动推荐知识。 +- 中途关闭按钮和半轮跨启动恢复。 +- 账号体系、跨设备同步与正式生产发布。 +- 具体 IP 形象与高级动画的重新设计。 +- 敲击背面等系统手势快捷触发。 + +## 17. 请产品重点复核的边界 + +本文已给出可实施的默认规则,但以下体验最容易产生不同理解,请产品审阅时重点确认: + +1. 空库 IP:当前定义为不抽卡且不表现成按钮;是否需要点击后用 IP 动作再次指向加号。 +2. 并行上传:当前定义为处理一张截图时加号仍可用;新截图会形成独立处理项。 +3. 离开半轮:当前定义为进入知识库/Profile/Settings 后结束当前半轮,返回需重新点击 IP。 +4. 处理失败:当前 MVP 只提供重试或重新上传,不提供用户编辑 AI 结果或补来源链接。 +5. 通知叠卡:当前定义为完成后回到通知前的首页状态,不改变底层牌组。 +6. Share Extension 首次授权:当前定义为先打开 Omo 完成授权,再继续同一截图;需确认是否接受这一步额外跳转。 + +这些边界一经产品确认,就应转化为自动状态测试和最终 TestFlight 真机验收步骤。 + +## 相关文档 + +- [[docs/product-principles]] +- [[docs/knowledge-library-prd]] +- [[docs/ios-api-data-contract-zh]] +- [[docs/frontend/v2-frontend-architecture]] +- [[docs/frontend/v2-layout-system]] +- [[docs/quality-baseline]] +- [[docs/staging-testflight-runbook]] diff --git a/docs/frontend/v2-frontend-architecture.md b/docs/frontend/v2-frontend-architecture.md index aa466df..0cb7ea1 100644 --- a/docs/frontend/v2-frontend-architecture.md +++ b/docs/frontend/v2-frontend-architecture.md @@ -5,7 +5,17 @@ ## 运行时边界 - `OmoApp.swift`:应用入口并注入唯一的 `OmoStore`。 -- `ContentView.swift`:今日、知识库、我的、上传和召回流程。 +- `ContentView.swift`:Figma 首页、知识库、我的、设置、上传和卡片详情路由;不再承载召回手势细节。 +- `RecallHomeView.swift`:首页、侧边菜单、持续存在的收藏夹/上传入口和最多十张的冻结牌组。 +- `RecallRoundView.swift`:当前牌堆、提交、失败重试与换卡编排。 +- `RecallKnowledgeCardView.swift`:四层卡堆、句内语义遮挡、80% 揭示和完整上下文 Sheet。 +- `RecallRatingSlider.swift`:取消区与 forgot / fuzzy / remembered 三个节点的拖动自评。 +- `RecallInteractionState.swift`:不依赖 View 的刮开、提交、失败、重试和换卡状态。 +- `KnowledgeLibraryView.swift`:Figma 知识库壳层、完整卡片浏览、搜索输入、结果状态和两列分页。 +- `KnowledgeLibraryViewModel.swift`:文字/语音查询状态、请求竞态、结果 ID 映射和恢复动作。 +- `KnowledgeLibrarySearch.swift`:可替换的搜索协议;Debug mock 与 Release 不可用 Adapter 明确隔离。 +- `KnowledgeLibraryPagination.swift`:根据 SwiftUI 实测高度分页的纯布局模型。 +- `KnowledgeLibrarySpeech.swift`:Apple Speech/Audio 权限、生命周期和转写事件边界。 - `MotionKit.swift`:逐帧图集、粒子、轨道、刮除和按钮反馈,不承载业务状态。 - `OmoStore.swift`:卡片集合、加载、上传、反馈和删除状态。 - `APIClient.swift`:设备隔离的 HTTP 合同。 @@ -16,8 +26,12 @@ ## 状态与兼容 - 加载、生成、空状态和错误均显式展示。 -- `MemoryCard` 的来源核验字段为可选,以兼容旧卡片。 +- `MemoryCard` 的来源核验字段和 `hiddenSemantic` 为可选,以兼容旧卡片;只有 `hiddenSemantic` 是 `coreKnowledge` 精确连续子串的到期卡进入召回牌组。 +- 点击 IP 时复制 `dueCards.prefix(10)` 形成本轮固定牌组;本轮中新上传或重新调度的卡不会改变现有顺序。 +- 召回发生在首页同一场景,收藏夹和上传入口在刮卡与自评期间仍可使用;Library 始终显示完整知识,不使用刮层。 +- 团队独立的 `ProfileView` 保持页面实现和数据来源,只增加从侧边菜单进入后的返回首页能力。 - Debug 启动参数仅用于 Simulator 路径验证,不改变 Release 行为。 +- 知识库生产向量搜索尚未接入;Release 不回退到本地字符串匹配,也不把 Debug Fixture 伪装成远端能力。 - 新增页面或状态前优先扩展现有 Store 和共享组件,避免恢复平行架构。 ## 相关文档 diff --git a/docs/frontend/v2-layout-system.md b/docs/frontend/v2-layout-system.md index 56d9682..d485e86 100644 --- a/docs/frontend/v2-layout-system.md +++ b/docs/frontend/v2-layout-system.md @@ -1,13 +1,35 @@ # Omo iOS 布局与动效系统 -当前界面以“抽取、主动回忆、揭示、反馈、收好”作为一条连续视觉叙事。动效服务于状态因果,不承担业务判断。 +当前界面以“首页 IP 抽取、句内主动回忆、刮开揭示、滑动自评、原位换卡”作为一条连续视觉叙事。动效服务于状态因果,不承担业务判断。 ## 视觉来源 -- `OmoTheme` 保存颜色、页面边距和圆角等语义 token。 +- `OmoTheme` 保存通用页面 token,`RecallPalette`、`RecallHomeMetrics`、`RecallCardMetrics` 与 `RecallRatingMetrics` 集中保存已确认 Figma 首页的颜色、402 × 874 参考画布、卡片和滑条尺寸。 - 同族卡片共享表面、圆角、阴影和内容节奏。 - 主要操作使用统一按钮样式,触控区域不小于 44pt。 -- 页面通过 safe area 组织顶部和底部导航,不使用散落的设备坐标补丁。 +- 首页参考坐标只存在于集中 Metrics 中并随可用画布等比缩放;Library 与 Profile 继续使用 safe area 和自适应布局,不增加按机型分支。 + +## 知识库 + +- 知识库以 402 × 874 Figma 画布为视觉基准,由 `KnowledgeLibraryMetrics` 集中管理位置;整页随可用画布等比缩放,不按机型分支。 +- 卡片正文使用 SF 字体并支持 Dynamic Type。分页的最终依据是隐藏测量层得到的真实 SwiftUI 高度,不按字数决定卡片高度;首帧只使用统一保守占位高度,测量完成后无感校正。 +- 两列分页保持搜索相关性阅读顺序,视觉旋转不改变 VoiceOver 顺序。极长单卡可在当前页纵向滚动,横向手势切换页组。 +- 搜索栏、卡片、返回、上传和恢复按钮均保持至少 44pt 命中区域;搜索状态变化不会隐藏收藏夹或上传入口。 + +## 主动回忆首页 + +- IP 是有可复习卡时的抽卡入口;收藏夹和上传按钮在空闲、抽取、刮开、自评和换卡状态持续可见。 +- 一轮最多十张,卡堆最多显示四层外观。下一张真实卡可发出轻微稀有度光芒;稀有度只是装饰,没有点击或确认步骤。 +- 顶卡连续呈现 `coreKnowledge`,只在 `hiddenSemantic` 的真实文字区域绘制刮层;前后文使用常规正文权重,揭示语义使用 semibold 与珊瑚强调色。 +- 低于 80% 时只呈现真实刮到的部分且不出现自评;达到 80% 后归一为完全揭示并显示滑条。 +- 不提供“直接揭晓”按钮。卡片右上次级入口打开完整上下文,不改变刮开状态。 + +## 四位置滑动自评 + +- 最左端 `0.00` 是取消区;`0.42`、`0.70`、`0.97` 依次对应忘记了、没记清、记住了。 +- 轨道先在全宽坐标中绘制一条固定渐变,拖动只改变前景遮罩宽度,不能把渐变重新压缩到当前填充长度。 +- 滑块描边使用相同绝对坐标下的颜色,内部箭头始终为品牌青绿色,不参与渐变。 +- 节点切换提供轻震动;在三种结果上松手提交并自动换卡,回到最左端松手取消且当前卡不切换。 ## “我的”页面层级 diff --git a/docs/index.md b/docs/index.md index 9cfcc78..fbd5aaa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,12 @@ ## 产品与合同 - [[docs/product-principles]]:产品北极星、证据、召回、隐私与体验护栏。 +- [[docs/frontend-interaction-prd]]:Omo MVP 从首次上传、处理恢复、抽卡、刮开、自评到知识库与通知的统一前端交互和验收基线。 +- [[docs/knowledge-library-prd]]:知识库完整卡片浏览、文字/语音搜索、分页与 mock 边界的 MVP 产品需求。 - [[docs/ios-api-data-contract-zh]]:当前截图主链、iOS 模型、API、兼容与失败语义。 +- [[docs/superpowers/specs/2026-08-02-active-recall-selective-reintegration-design]]:以团队最新主线为基准,选择性恢复已验收首页与主动回忆交互的实施规格。 +- [[docs/superpowers/specs/2026-08-03-knowledge-library-search-design]]:知识库完整卡片浏览、文字/语音输入、分页与可替换搜索层的设计规格。 +- [[docs/superpowers/plans/2026-08-03-knowledge-library-search]]:知识库页面的测试先行实施步骤与 Simulator 验收计划。 - [[docs/privacy-policy-zh]]:面向用户的隐私政策。 - [[docs/support-zh]]:面向用户的支持说明。 @@ -14,10 +19,14 @@ - [[docs/frontend/v2-frontend-architecture]]:当前精简 SwiftUI 视图、状态与 API 边界。 - [[docs/frontend/v2-layout-system]]:当前布局 Token、召回动效和 UI 验证要求。 - [[docs/asset-provenance]]:App 素材来源、授权、处理方式与哈希。 +- [[docs/knowledge-library-validation]]:知识库页面的 Simulator 路径、截图与未验证边界。 +- [[docs/core-interaction-release-validation]]:核心交互的 Release UI Test、真实 staging、截图与 TestFlight 验收证据。 +- [[docs/staging-testflight-runbook]]:隔离 Railway staging、migration、部署与 TestFlight 运行手册。 ## 工程与协作 - [[docs/quality-baseline]]:验证矩阵、UI 原则门禁与证据分级。 +- [[docs/postgres-persistence]]:PostgreSQL Store、顺序 migration、readiness、导入与恢复边界。 - [[docs/issue-management-workflow]]:Issue、Project、临时 Plan 与 PR 的统一流转、标签和完成门槛。 - [[docs/documentation-guide]]:稳定文档、临时计划和决定记录的分层方式。 - [[docs/decision-log]]:重大且难以逆转的决定。 diff --git a/docs/ios-api-data-contract-zh.md b/docs/ios-api-data-contract-zh.md index 3952621..171530c 100644 --- a/docs/ios-api-data-contract-zh.md +++ b/docs/ios-api-data-contract-zh.md @@ -1,6 +1,6 @@ # Omo iOS API 合同 -所有请求使用 JSON,并通过 `X-Device-Id` 隔离本机卡片。 +所有请求使用 JSON,并通过 `X-Device-Id` 隔离本机卡片。该请求头当前只是不透明的过渡 owner key,不是认证或可信账号边界;生产登录与授权由 #19 定义。 ## 接口 @@ -9,19 +9,51 @@ | GET | `/api/health` | Liveness;只证明服务进程可响应 | | GET | `/api/readiness` | 模型、来源服务与存储依赖状态 | | GET | `/api/memory-cards` | 获取当前设备的全部卡片 | -| POST | `/api/sources/image-flow` | 上传 Base64 截图并生成一张卡 | +| POST | `/api/memory-cards/search` | 在当前设备卡片内进行语义排序,返回卡片 ID | +| POST | `/api/screenshot-jobs` | 快速接收截图,返回 `202 + ScreenshotJob` | +| GET | `/api/screenshot-jobs` | 获取当前设备的截图任务 | +| GET | `/api/screenshot-jobs/:id` | 轮询单个截图任务 | +| POST | `/api/screenshot-jobs/:id/retry` | 用同一截图重试失败任务 | +| POST | `/api/sources/image-flow` | 旧构建兼容接口;同步上传并生成一张卡 | | POST | `/api/memory-cards/:id/assessments` | 提交 remembered / fuzzy / forgot | | DELETE | `/api/memory-cards/:id` | 删除卡片 | +所有 JSON 响应均返回 `Cache-Control: no-store`;iOS 客户端也使用忽略本地缓存的请求策略,避免上传或自评后继续读取旧卡片列表。 + +## ScreenshotJob + +截图生成不再由一个长连接表示完整生命周期。客户端先压缩图片,在本地保存任务元数据和重试副本,再提交 `POST /api/screenshot-jobs`;服务端持久化任务后必须快速返回 202。任务公共结构为: + +```json +{ + "id": "job-...", + "state": "accepted", + "createdAt": "2026-08-08T00:00:00.000Z", + "updatedAt": "2026-08-08T00:00:00.000Z", + "attemptCount": 0, + "cardId": "", + "errorCode": "", + "errorMessage": "", + "retryable": false +} +``` + +`state` 只允许 `accepted`、`processing`、`succeeded`、`failed`。同一 owner 重复提交相同截图必须返回同一个 job ID;失败重试复用该 ID。服务端内部使用截图指纹、五分钟 worker lease 和每次 claim 唯一的 attempt token;活跃 worker 每分钟续租。这些字段以及截图正文不得出现在 API 响应中。只有持有当前 attempt token 的 worker 可以写入终态,过期 worker 不得覆盖后续 attempt。服务重启或滚动发布只重新领取租约已过期的 processing 任务。 + +服务端仅在 `accepted / processing` 期间临时持久化压缩截图;任务进入 `succeeded / failed` 时必须清空服务端截图字段。iOS 在任务成功时删除本地重试副本,失败时保留供用户明确重试。启动加载如果发现服务端已经成功,也必须补做本地副本清理。 + +生成失败使用脱敏稳定码和可操作中文文案;上游正文、截图、模型载荷和 attempt token 永不返回。兼容接口 `/api/sources/image-flow` 暂留给旧 TestFlight 构建,新 iOS 不得继续调用。 + ## MemoryCard ```json { "id": "card-...", "generationMode": "qwen", - "coreKnowledge": "值得记住的核心知识", + "coreKnowledge": "截图可能削弱记忆,因为它会触发认知卸载。", + "hiddenSemantic": "认知卸载", "recallCue": "主动回忆提示", - "answer": "揭晓答案", + "answer": "认知卸载", "explanation": "简短解释", "sourceTitle": "【巫师】财经跨年:中国财经年度盘点Top10", "sourceAccount": "巫师财经", @@ -41,7 +73,21 @@ } ``` -`generationMode` 为 `qwen` 时表示来自模型 Adapter,为 `fixture` 时表示非生产环境显式开启的固定测试结果。`sourceStatus` 为 `verified` 时表示 TikHub 候选的标题与作者均通过严格匹配;`screenshot_only` 表示没有可靠来源,只使用截图证据。`sourceReason` 使用 `provider_missing`、`provider_timeout`、`provider_unavailable`、`provider_invalid_response`、`provider_rejected`、`identity_incomplete` 或 `strict_match_not_found` 区分原因;任何失败都不能返回 `verified`。服务端内部保存调度步数和反馈幂等标识,但不暴露给 iOS。R / SR / SSR 不参与调度。 +新生成卡片的 `hiddenSemantic` 必须非空,并且是 `coreKnowledge` 中字符完全一致的连续子串;它是主动回忆时唯一被遮住的承重语义。`answer` 暂时为旧客户端保留,新卡固定镜像 `hiddenSemantic`,不得成为另一份答案。旧记录缺少合法 `hiddenSemantic` 时仍可被 iOS 解码并在知识库完整展示,但不进入主动回忆牌组。 + +## 知识库搜索客户端合同 + +iOS 通过 `KnowledgeLibrarySearching` 向 `POST /api/memory-cards/search` 只提交 `{ "query": "..." }` 和既有 `X-Device-Id`。候选卡片不能由客户端提交;服务端必须先按 owner 从 Store 读取候选,再交给 Qwen 做语义相关性排序。接口只返回 `{ "orderedCardIDs": [...] }`,不得向客户端返回卡片正文、embedding、内部相似度或其他 owner 的 ID。 + +当前 MVP 是请求时语义重排,不维护持久化向量索引,因此文案和埋点不得把它误称为本地向量数据库。服务端会过滤未知与重复 ID并保持模型给出的稳定顺序;空查询返回 `422 search_query_required`,模型未配置返回 `503 search_not_configured`,上游失败/超时/无效结果分别使用 `search_upstream_error`、`search_timeout`、`search_invalid_response` 等脱敏稳定码。界面只映射仍存在的卡片;空结果与请求失败是不同状态,新请求必须使旧响应失效,失败时不得残留旧结果。 + +Debug/test 只有显式启动参数才使用确定性合成匹配器;普通 Debug 与所有 Archive/Release 均使用真实 API Adapter。 + +语音入口只把 Apple Speech 的最终转写文本交给同一搜索合同,不保存原始音频。原始查询、转写文本和卡片全文不得直接进入分析事件。 + +`generationMode` 为 `qwen` 时表示来自模型 Adapter,为 `fixture` 时表示非生产环境显式开启的固定测试结果。`sourceStatus` 为 `verified` 时表示 TikHub 候选的标题与作者均通过严格匹配;`screenshot_only` 表示没有可靠来源,只使用截图证据,且稀有度固定为 R。`sourceReason` 使用 `provider_missing`、`provider_timeout`、`provider_unavailable`、`provider_invalid_response`、`provider_rejected`、`identity_incomplete` 或 `strict_match_not_found` 区分原因;任何失败都不能返回 `verified`。服务端内部保存调度步数和反馈幂等标识,但不暴露给 iOS。R / SR / SSR 不参与调度。 + +模型首次返回字段缺失或 `hiddenSemantic` 不满足连续子串合同时,服务端使用同一截图和校验原因修复一次;第二次仍不合法则返回脱敏的 `502 model_invalid_response`,不验证来源、不保存卡片,也不允许客户端自行猜词。 ## Liveness、readiness 与运行模式 @@ -65,9 +111,17 @@ "checks": { "model": { "required": true, "ready": true, "provider": "qwen" }, "source": { "required": true, "ready": true, "provider": "tikhub" }, - "storage": { "required": true, "ready": false, "driver": "json", "durable": false } + "storage": { + "required": true, + "ready": false, + "driver": "postgres", + "durable": true, + "reason": "storage_migration_required", + "appliedVersions": ["001"], + "pendingVersions": ["002"] + } }, - "blockers": ["durable_storage_unavailable"], + "blockers": ["storage_migration_required"], "warnings": [] } ``` @@ -77,8 +131,8 @@ ```json { "code": "service_not_ready", - "message": "生产依赖尚未就绪。", - "blockers": ["durable_storage_unavailable"] + "message": "服务依赖尚未就绪。", + "blockers": ["storage_migration_required"] } ``` @@ -94,11 +148,22 @@ | Qwen | `QWEN_API`、`QWEN_BASE_URL`、`QWEN_MODEL`、`QWEN_TIMEOUT_MS` | | TikHub | `TIKHUB_API_KEY`、`TIKHUB_BASE_URL`、`TIKHUB_TIMEOUT_MS` | | 本地 JSON Store | `CARD_STORE_PATH` | +| PostgreSQL | `DATABASE_URL`、`DATABASE_POOL_MAX`、`DATABASE_CONNECT_TIMEOUT_MS`、`DATABASE_IDLE_TIMEOUT_MS` | -`BASE_URL`、`AI_MODEL`、`MODEL_REQUEST_TIMEOUT_MS` 和误拼的 `TICKHUB_API_KEY` 只作为迁移期兼容别名,新的部署与文档不得继续使用。当前 `CARD_STORE_PATH` 指向的 JSON Store 不属于耐久生产存储;不得把 `DATABASE_URL` 等尚未接入的变量写成已支持。 +`BASE_URL`、`AI_MODEL`、`MODEL_REQUEST_TIMEOUT_MS` 和误拼的 `TICKHUB_API_KEY` 只作为迁移期兼容别名,新的部署与文档不得继续使用。当前 `CARD_STORE_PATH` 指向的 JSON Store 不属于耐久生产存储;提供 `DATABASE_URL` 时不会回退 JSON,连接和顺序 migration 必须通过才能满足 storage readiness。完整迁移、导入和恢复合同见 [[docs/postgres-persistence]]。 如果运行时仍检测到兼容别名,readiness 的 `warnings` 会返回 `deprecated_environment_variable:<NAME>`;它只包含变量名,不包含变量值。 +PostgreSQL 持久化使用 `(owner_id, card_id)` 作为 canonical key;重复截图不会覆盖已存在卡片的 mastery、assessment 或 schedule。assessment 的 `attemptId` 在数据库内唯一,重复提交只返回当前状态;并发更新使用行锁与版本 fencing。数据库或 migration 未就绪时业务请求返回 `service_not_ready`,驱动原始错误、连接串和 SQL 参数不会进入响应。 + +`NODE_ENV=production` 时必须显式设置 `STORE_DRIVER=postgres`;缺失、非法值、与 `DATABASE_URL` 不匹配或任何 JSON 回退都使 readiness fail closed。Migration 只由部署流程显式执行,不在服务进程启动时自动修改数据库。 + +## iOS 运行环境合同 + +iOS 从生成的 Info.plist 键 `OmoAPIBaseURL` 读取真实服务地址。Debug 可使用进程环境变量 `OMO_API_BASE_URL` 覆盖,并在两者均缺失时只回退到 `http://127.0.0.1:5174`;非 Debug 构建忽略进程环境变量,只接受构建时注入的 HTTPS URL。缺失或无效配置必须显示可恢复错误,不能回退到任何历史生产域名。 + +知识库合成卡与 Mock 搜索仍由 `#if DEBUG` 包围,并且只有启动参数 `-OmoLibraryFixture`、`-OmoLibraryMockSearch` 或具体搜索验收参数显式出现时才启用。普通 Debug 启动与所有 Archive/Release 构建均走真实 Adapter 或明确的不可用状态,不得自动展示 Fixture 成功。 + ## 掌握阶段状态机 - `remembered` 推进一个掌握阶段,最高停在 `engraved`。 diff --git a/docs/knowledge-library-prd.md b/docs/knowledge-library-prd.md new file mode 100644 index 0000000..7492120 --- /dev/null +++ b/docs/knowledge-library-prd.md @@ -0,0 +1,133 @@ +# Omo 知识库浏览与搜索 PRD + +版本:MVP 0.1 +更新:2026-08-03 +状态:实施中;本地 mock 搜索不代表生产向量服务已上线 + +## 产品目标 + +当用户想使用曾经保存的知识,却只剩模糊印象时,知识库应让用户用浏览、文字描述或语音描述快速找回已有的完整知识卡片。它不是复习流程:知识库内的卡片不刮开、不提问,直接显示整理好的完整知识。 + +## 用户故事 + +- 作为已经保存过截图的用户,我进入知识库即可看到全部完整知识卡片。 +- 当卡片太多时,我可以描述“我想找的知识”,获得相关卡片,而不用记住标题或准确关键词。 +- 不方便打字时,我可以口述查询,并在转写后直接获得搜索结果。 +- 结果放不下一页时,我可以横向翻页,并始终知道自己位于第几页。 +- 我可以打开任一卡片查看完整上下文,也可以从知识库继续上传截图。 + +## 入口与页面结构 + +1. 首页收藏夹进入知识库。 +2. 返回按钮回到首页。 +3. 顶部搜索框包含文字输入、清除、麦克风和“帮我找”。 +4. 主体展示两列错落完整卡片。 +5. 底部页点显示分页位置;加号进入已有照片选择与上传流程。 +6. 点击卡片进入已有“完整知识”详情。 + +## 文字搜索 + +- 空查询展示全部卡片。 +- 键盘 Search 和“帮我找”执行同一提交动作。 +- 输入过程不自动发请求;用户提交后才检索。 +- 清空输入立即取消未完成请求、恢复全部卡片并回到第一页。 +- 新请求取消旧请求;旧响应即使晚到也不得覆盖新结果。 +- 结果按相关性排序,但不显示内部相似度分数。 +- 未知、重复或已删除的返回 ID 会被过滤。 + +## 语音搜索 + +- 点击麦克风后按需请求语音识别和麦克风权限。 +- 监听时实时更新输入框;再次点击可停止。 +- 最终转写非空时自动提交一次搜索。 +- 权限拒绝、识别器不可用和转写失败分别反馈;文字搜索始终可用。 +- 不保存原始音频,搜索层只接收转写后的文字。 +- Simulator 使用显式 Debug 转写注入验证交互;真机识别质量不属于本轮已验证能力。 + +## 卡片与分页 + +- 卡片完整显示 `coreKnowledge`,不截断。 +- 合法 `hiddenSemantic` 可用字重和颜色强调,但绝不遮挡。 +- 稀有度仅用于视觉装饰,不代表相关度、抽取概率或掌握程度。 +- 卡片最终高度由实际 SwiftUI 排版测量决定,不按字数硬编码;首帧只用统一占位高度避免等待排版,实测值随后成为唯一分页依据。 +- 每页采用两列错落布局;按结果顺序放入较短列,放不下时进入下一页。 +- 查询、卡片、容器尺寸或 Dynamic Type 改变时重新分页并回到第一页。 +- 横向滑动切页;页点动态反映真实页数和当前位置。 + +## 页面状态 + +| 状态 | 触发 | 页面行为 | 主要恢复动作 | +|---|---|---|---| +| 全部卡片 | 查询为空 | 展示全部完整卡片 | 搜索或打开卡片 | +| 编辑中 | 输入未提交 | 保留当前卡片 | 提交/清除 | +| 搜索中 | 提交非空查询 | 显示真实加载反馈,避免操作旧结果 | 等待或清除 | +| 有结果 | 返回至少一张卡 | 分页展示相关卡片 | 打开、改查、清除 | +| 无结果 | 成功返回空数组 | 明确“没有找到”,不伪装失败 | 查看全部/改查 | +| 搜索失败 | 超时或服务错误 | 保留查询,显示失败原因 | 重试/清除 | +| 空知识库 | 没有任何卡片 | 说明需要先上传截图 | 上传 | +| 语音监听 | 权限通过并开始采集 | 实时转写与监听态反馈 | 停止 | +| 语音受限 | 拒绝、不可用或失败 | 说明原因,保留文字输入 | 设置/重试/打字 | + +## 搜索数据边界 + +iOS 依赖 `KnowledgeLibrarySearching`,输入是查询与当前用户已加载卡片构成的候选文档,输出是相关性有序的卡片 ID。UI 不依赖具体 embedding、向量库或模型。 + +本 MVP 的 Debug/test 构建使用确定性的合成 mock:它只用于页面开发、自动测试与 Simulator 状态复现,不得被称作真实向量或语义检索。Release 默认使用不可用 Adapter,直到后端真实服务实现并通过权限、索引、删除一致性、延迟与质量验证。 + +未来真实服务必须: + +- 只检索鉴权用户自己的卡片; +- 与卡片新增、更新和删除保持索引一致; +- 返回稳定有序且属于候选集合的卡片 ID; +- 支持取消、超时和明确错误; +- 不向客户端返回 embedding 或内部相似度; +- 遵循查询与语音转写的隐私披露和保留策略。 + +## 可访问性与系统行为 + +- 所有操作目标至少 44 × 44 pt。 +- 视觉旋转不改变 VoiceOver 阅读顺序;一张卡作为一个完整按钮元素。 +- 页点整体读作“第 X 页,共 Y 页”。 +- 搜索完成、无结果、失败与语音状态有可感知反馈。 +- 支持 Dynamic Type、Reduce Motion、键盘焦点和安全区;不得横向溢出。 +- 固定品牌浅色外观在系统深色模式下仍需满足文字对比度。 + +## MVP 非目标 + +- 生产 embedding、向量数据库与索引任务。 +- 分类、标签、筛选、知识图谱、推荐、搜索历史和卡片编辑。 +- 修改主动回忆抽卡、刮刮乐、自评或调度。 +- 根据搜索内容生成新卡片或改写旧卡。 + +## 事件定义 + +本轮只冻结事件名称,不引入新的分析 SDK: + +- `knowledge_library_opened` +- `knowledge_library_search_submitted`(`input_method`: `text|voice`) +- `knowledge_library_search_completed`(结果数量、页数、耗时桶;不记录原始查询) +- `knowledge_library_search_failed`(错误类别;不记录原始查询) +- `knowledge_library_card_opened` +- `knowledge_library_page_changed` +- `knowledge_library_upload_opened` + +不得把原始查询、转写文本或卡片全文直接写入分析事件。 + +## 验收标准 + +- Figma node `884:324` 的橙色背景、奶油面板、返回/角色、搜索框、错落卡片、页点、收藏夹和上传结构可辨识还原。 +- 全部卡片、文字搜索、语音模拟、加载、有结果、无结果、失败、空库、详情和上传均可从 Debug App 稳定复现。 +- 长短卡片完整显示,分页由真实测量高度决定。 +- 请求竞态、去重、失败重试、分页算法和语音衔接有自动测试。 +- 常见与不同尺寸 iPhone、Dynamic Type、Reduce Motion、VoiceOver、键盘和安全区经过 Simulator/人工验收并保存截图证据。 +- PR 明确列出未验证的生产向量检索和真机语音能力。 + +## 相关文档 + +- [[docs/index]] +- [[docs/product-principles]] +- [[docs/ios-api-data-contract-zh]] +- [[docs/frontend/v2-frontend-architecture]] +- [[docs/frontend/v2-layout-system]] +- [[docs/knowledge-library-validation]] +- [[docs/superpowers/specs/2026-08-03-knowledge-library-search-design]] diff --git a/docs/knowledge-library-validation.md b/docs/knowledge-library-validation.md new file mode 100644 index 0000000..5449037 --- /dev/null +++ b/docs/knowledge-library-validation.md @@ -0,0 +1,44 @@ +# 知识库页面验证记录 + +日期:2026-08-03 +分支:`codex/knowledge-library-search` +设备:Omo Verify iPhone 17 Pro,iOS 26.5 Simulator,402 × 874 pt 参考画布 + +## 已验证路径 + +| 场景 | 操作/数据 | 结果 | 证据 | +|---|---|---|---| +| 全部卡片 | `-OmoLibraryFixture many` | 完整知识卡两列显示,卡片不刮开,长文不截断,页点为 3 页 | `docs/assets/knowledge-library/all-cards.png` | +| 文字搜索结果 | 查询“认知卸载” | 显示相关完整卡片;首帧占位后由实测高度完成分页,不停留在排版转圈 | `docs/assets/knowledge-library/search-results.png` | +| 语音搜索 | Debug 转写“如何避免认知卸载”,点击麦克风 | 转写进入输入框并自动提交一次搜索 | `docs/assets/knowledge-library/voice-search.png` | +| 无结果 | 显式空结果 Adapter | 与服务失败区分,可一键回到全部卡片 | `docs/assets/knowledge-library/no-results.png` | +| 搜索失败 | 显式失败 Adapter | 保留查询,显示重试动作 | `docs/assets/knowledge-library/search-failure.png` | +| 空知识库 | `-OmoLibraryFixture empty` | 显示上传第一张截图动作 | `docs/assets/knowledge-library/empty-library.png` | +| 完整上下文 | 点击第一张知识卡 | 打开已有完整知识详情,显示稀有度、掌握状态、正文、解释与来源 | `docs/assets/knowledge-library/card-detail.png` | +| 上传入口 | 点击右下角加号 | 打开已有截图选择流程;未选择或上传任何私人照片 | `docs/assets/knowledge-library/upload-sheet.png` | +| 键盘焦点 | 点击文字搜索框 | 系统键盘出现,Search submit label 可见 | `docs/assets/knowledge-library/text-input-keyboard.png` | +| 小屏布局 | Omo Verify iPhone SE 3,iOS 26.5 | 同一画布等比缩放,无机型分支;搜索、完整卡片、页点和上传均在安全区内 | `docs/assets/knowledge-library/small-device.png` | +| Accessibility 字号 | iPhone SE 3,`accessibility-extra-large` | 正文随系统放大;超高卡片在页内纵向滚动,页点和上传入口不被测量层推出画布 | `docs/assets/knowledge-library/dynamic-type.png` | + +## 代码与合同检查 + +- Debug mock、失败、空结果和合成卡片均由启动参数显式启用;Release 使用不可用搜索 Adapter。 +- 文字和语音共用 `KnowledgeLibrarySearching`,后返回的旧请求不能覆盖新查询;未知和重复结果 ID 被过滤。 +- 分页算法为纯 Swift 模型,卡片真实高度来自 SwiftUI Preference 测量;统一首帧占位不根据字数推断高度。 +- 语音使用 Apple Speech/AVAudioSession;原始音频不落盘,最终转写才进入搜索。 +- serve-sim 的 Reduce Motion 系统开关可正常启用,知识库结果、操作和布局不依赖动画。 +- `xcodebuild build` 与 `build-for-testing` 已通过。iOS 26.5 XCTest runner 在本机持续停在 `waiting for workers to materialize`,因此单元测试只证明已编译,未声明实际执行通过。 + +## 未验证 + +- 生产向量检索尚未接入,未验证 embedding、索引一致性、线上延迟或搜索质量。 +- 真机麦克风、真实语音识别质量和 App Store 权限文案仍需真机验证。 +- serve-sim 能点击麦克风、卡片和上传入口;其鼠标拖动没有产生 iOS TabView 横向手势,分页算法和页点已在代码/编译层覆盖,但本轮不把该工具限制写成真实滑动通过。 +- iOS 26.5 Simulator 首次开启 VoiceOver 会停在系统“旁白手势”教学弹窗;代码层已审查卡片、页点、搜索、返回和上传的 label/hint/阅读顺序,但本轮不声明完整 VoiceOver 手势走查通过。 + +## 相关文档 + +- [[docs/knowledge-library-prd]] +- [[docs/frontend/v2-frontend-architecture]] +- [[docs/frontend/v2-layout-system]] +- [[docs/quality-baseline]] diff --git a/docs/postgres-persistence.md b/docs/postgres-persistence.md new file mode 100644 index 0000000..07ab075 --- /dev/null +++ b/docs/postgres-persistence.md @@ -0,0 +1,125 @@ +# Omo PostgreSQL 持久化合同 + +本页描述 Omo 当前 PostgreSQL Store、顺序 migration、readiness、合成 JSON 导入与恢复边界。它不表示 Railway 或任何生产数据库已经部署、迁移或验证。 + +## 选择 Store + +- 未设置 `DATABASE_URL`:使用 JSON Store,只允许本地开发;生产 readiness 必须失败。 +- 设置且通过校验的 `DATABASE_URL`:使用 PostgreSQL Store;只有连接成功且所有 migration 版本与 checksum 一致时 storage readiness 才成功。 +- 设置了错误的 `DATABASE_URL` 或连接池参数:不回退 JSON,readiness 与业务请求 fail closed。 + +canonical 环境变量: + +| 名称 | 默认值 | 作用 | +|---|---:|---| +| `DATABASE_URL` | 空 | PostgreSQL 连接串;不得写入仓库或日志 | +| `DATABASE_POOL_MAX` | `10` | 单个进程的最大连接数 | +| `DATABASE_CONNECT_TIMEOUT_MS` | `5000` | 建立连接的超时 | +| `DATABASE_IDLE_TIMEOUT_MS` | `30000` | 空闲连接释放时间 | + +TLS、证书与目标网络参数应由 `DATABASE_URL` 和部署平台合同提供。本仓库不提交证书、密码或内部地址。多实例/Serverless 的总连接数需要在 #20 的真实部署中按实例数和数据库上限重新核算,本地默认值不是生产容量结论。 + +## Schema 与迁移 + +迁移位于 `backend/migrations/`,只允许新增 `NNN-description.sql`: + +- `001`:建立 `omo_owners`、`omo_memory_cards` 与 owner/time 查询索引。 +- `002`:为卡片增加单调 `version`,并建立有唯一约束的 `omo_assessment_attempts`。 + +`omo_schema_migrations` 记录版本、文件名、SHA-256 checksum 和应用时间。runner 在同一事务中获取 PostgreSQL advisory lock,检查已应用版本和 checksum,再顺序执行待应用文件。已发布 migration 不得改写;checksum 漂移、未知版本或目标版本落后都会停止。 + +服务启动不会自动执行 migration。显式命令: + +```bash +npm --prefix backend run db:migrate +npm --prefix backend run db:check +``` + +`db:migrate` 只输出版本与结果码,不输出连接串或 SQL 参数。`db:check` 与 `/api/readiness` 都只检查连接和完整 migration 集,不修改业务数据。 + +## Owner 与身份边界 + +当前 API 仍把 `X-Device-Id` 当作不透明 owner key,并在首次写卡时建立 `owner_kind=device` 的 owner。它不是认证、授权或可信账号边界,调用方可以伪造;生产账号、会话、设备到账号迁移与受保护数据访问由 #19 定义。 + +因此 PostgreSQL 持久化通过不等于账号系统或生产 beta 已完成,也不能独立解除 #20 对账号闭环的阻塞。 + +## 写入、幂等与并发 + +- 卡片 canonical key 是 `(owner_id, card_id)`。重复 capture 使用 `ON CONFLICT DO NOTHING` 并返回第一次保存的卡,不覆盖 mastery、assessment 或 schedule。 +- assessment 在事务内锁定卡片行;`(owner_id, card_id, attempt_id)` 唯一,重复 attempt 返回当前卡且不重复计数。 +- assessment 更新同时检查卡片 `version`;不符合预期版本时返回 `storage_write_conflict`,不静默覆盖。 +- 删除使用数据库级原子删除,并级联删除 assessment attempts。与 assessment 并发时,事务锁保证最终卡片不会复活。 +- 驱动、网络、SQL 和约束原始错误不会进入 API;调用方只看到稳定的 `storage_unavailable`、`storage_write_conflict` 或 readiness blocker。 + +## JSON 导入 + +JSON Store 不会在服务启动时自动搬迁。先运行 migration,再用合成或已获授权的数据显式导入: + +```bash +npm --prefix backend run db:import-json -- \ + --file=/absolute/path/to/cards.json \ + --dry-run + +npm --prefix backend run db:import-json -- \ + --file=/absolute/path/to/cards.json \ + --confirm-authorized-data +``` + +非 dry-run 必须提供 `--confirm-authorized-data`。导入按 canonical key 幂等处理;已有卡不覆盖。命令只报告 scanned/imported/existing 数量,不打印 owner、卡片内容或文件中的数据。 + +## 本地验证与恢复 + +需要本机 PostgreSQL 工具链: + +```bash +npm --prefix backend run test:postgres +``` + +该命令只在临时目录创建绑定 `127.0.0.1` 的 PostgreSQL 集群和合成数据库,验证: + +- 空库 migration、从 `001` 升级到 `002`、并发 runner 与 checksum 漂移; +- 重复 capture、重启服务回读、assessment 幂等与并发; +- assessment/delete 竞争、合成 JSON dry-run/导入; +- `pg_dump` 到新数据库的 `pg_restore` 与合成卡片回读。 + +测试结束会停止临时集群并删除临时目录。本地演练不证明 Railway 网络、真实数据库权限、生产负载、备份保留或灾难恢复时限。 + +生产迁移/恢复必须使用 manual Plan 和人工授权。默认策略是先停止写流量、记录应用与 migration 版本、创建并验证备份,再执行迁移。仓库不提供破坏性 down migration;如必须恢复数据,应恢复到新的数据库、验证 migration 与业务回读,再经授权切换连接,不直接覆盖原库。 + +## Readiness + +PostgreSQL storage readiness 的安全字段: + +```json +{ + "required": true, + "ready": true, + "driver": "postgres", + "durable": true, + "reason": "", + "appliedVersions": ["001", "002"], + "pendingVersions": [] +} +``` + +常见 blocker: + +- `database_url_invalid` +- `database_pool_max_invalid` +- `database_connect_timeout_invalid` +- `database_idle_timeout_invalid` +- `storage_not_checked` +- `storage_migration_required` +- `storage_migration_drift` +- `storage_migration_unknown` +- `storage_unavailable` + +这些字段不包含连接串、数据库主机、用户名、owner 或卡片内容。 + +## 相关文档 + +- [[AGENTS]] +- [[docs/ios-api-data-contract-zh]] +- [[docs/quality-baseline]] +- [[docs/decision-log]] +- [[docs/issue-management-workflow]] diff --git a/docs/privacy-policy-zh.md b/docs/privacy-policy-zh.md index 555236a..9b2b87e 100644 --- a/docs/privacy-policy-zh.md +++ b/docs/privacy-policy-zh.md @@ -1,89 +1,51 @@ # Omo 隐私政策 -更新日期:2026 年 7 月 2 日 +更新日期:2026 年 8 月 8 日 -Omo 是一款把用户主动保存的截图或平台内容转化为有证据绑定的记忆卡,并通过主动回忆和复习调度帮助用户再次想起内容的 iOS 应用。当前版本不强制注册账号,主要使用匿名设备身份保存当前设备下的数据。后续如果提供 Apple 登录、跨设备同步、订阅或更多云端能力,本政策会同步更新。 +Omo 把用户主动选择的截图转化为记忆卡,并通过主动回忆帮助用户再次想起内容。当前 TestFlight 版本不要求注册账号,使用随机生成的匿名设备标识区分不同设备的数据。 -## 我们收集哪些信息 +## 我们处理和保存的信息 -为了提供生成和学习功能,Omo 会收集和保存以下信息: +- 用户主动选择的截图。Omo 测试服务会临时保存压缩截图,以便在网络波动或服务重启后继续完成 AI 识别和生成;截图任务成功或失败后,服务端截图副本会被删除。iOS 设备在任务成功后删除本地重试副本;失败时会保留该副本供用户重试。 +- 生成后的记忆卡,包括核心知识、承重语义、回忆问题、解释和来源信息。 +- 学习记录,包括三档自评、复习次数和下次复习时间。 +- 随机生成的匿名设备标识,用于关联当前设备的记忆卡和学习记录。 +- 用户提交的文字搜索词,以及语音搜索经 Apple 语音识别得到的文字,用于检索相关卡片。搜索词当前不保存到 Omo 数据库。 -- 用户主动提交的截图、平台链接、提取到的正文或字幕、标题、作者和来源信息。 -- 系统生成的记忆卡、题目变式、解释、证据片段、反馈和复习时间。 -- 学习记录,包括答题结果、已完成进度、当前继续学习位置、收藏、反馈和通知状态。 -- 匿名设备 ID,用于区分不同设备下的数据。 -- 如果用户允许系统通知,Omo 会保存当前设备的 APNs 推送 token,用于发送卡片生成状态或复习提醒。 -- 服务端诊断信息,例如生成失败类型、请求状态、队列状态和必要的错误日志,用于排查生成、通知和稳定性问题。 -- 生成额度使用记录,用于执行每日免费生成次数限制和防止滥用。 +当前版本不要求用户提供真实姓名、手机号、邮箱、通讯录、定位或广告标识符,不包含广告 SDK,也不进行跨 App 或网站追踪。 -当前版本不主动收集真实姓名、手机号、通讯录、定位、相册、相机、麦克风、广告标识符或金融信息。 +## 使用目的 -## 我们如何使用这些信息 +上述信息只用于生成、保存、检索和复习记忆卡,以及维持服务的安全与可用性。Omo 不出售用户数据,也不会公开展示用户提交的内容。 -我们使用这些信息来: +## 第三方 AI 与用户许可 -- 恢复截图或平台内容中的来源、上下文和核心记忆点。 -- 生成一张有证据绑定的记忆卡、题目变式和解释。 -- 保存记忆卡、复习进度、通知和反馈。 -- 在内容生成完成或失败时发送系统通知提醒。 -- 执行每日免费生成额度,控制服务成本和异常滥用。 -- 改进生成质量、学习流程和产品稳定性。 -- 帮助用户在当前设备上继续未完成的学习。 +为了理解截图、生成记忆卡和执行语义搜索,必要的截图、搜索词或卡片文本会被发送给第三方 AI 服务处理。Omo 会在首次发送截图前说明这一处理方式并取得用户许可;用户可以取消,也可以在 App 的“Settings”中关闭后续新截图的 AI 处理许可。 -我们不会把用户提交内容用于广告追踪,不会向第三方出售用户数据,也不会把用户提交内容公开展示给其他用户。 +请不要上传含密码、证件号码、金融信息、医疗信息或其他敏感个人信息的截图。第三方 AI 服务会依据其服务协议和数据处理约定处理请求。 -## 第三方 AI 模型处理 +## 语音输入 -为了理解截图并生成记忆卡,用户提交的图片、平台链接、提取到的正文或字幕和必要上下文可能会被发送给第三方 AI 模型服务进行处理。 - -Omo 会在用户首次进行真实 AI 生成前展示 AI 处理说明。用户可以选择同意后继续生成,也可以暂不生成。我们会尽量只发送完成生成所需的内容,并避免发送与生成无关的信息。 - -第三方 AI 模型服务可能根据其服务条款和隐私政策处理请求数据。 - -## 匿名设备身份和账号 - -当前版本主要使用匿名设备 ID 区分用户数据,不要求注册或登录账号。匿名设备 ID 不是用户真实姓名或手机号,但它会用于关联当前设备下的记忆卡、复习记录、通知和生成额度。 - -匿名设备方案有明确边界: - -- 同一台设备、同一 App 身份上下文下,数据可以持续保存。 -- 如果用户删除 App、换机、重装后 Keychain 数据不可用,或安装到不同 App 身份上下文,历史数据可能无法自动恢复。 -- 如果后续提供 Apple 登录,Omo 会提供更明确的数据绑定、恢复和删除说明。 - -如果未来支持账号创建,App 内会提供账号删除入口。删除账号会删除账号及其关联数据,除非法律或安全原因要求保留最小必要记录。 +语音搜索使用 Apple 的语音识别能力将语音转成文字。Omo 只把识别后的搜索文字发送给自己的测试服务,不在自己的服务器保存原始录音。用户也可以完全使用文字搜索。 ## 数据保存和删除 -Omo 会在云端保存当前匿名设备下的记忆卡、题目变式、复习记录、通知、生成任务和必要诊断记录。 +Omo 在隔离的 TestFlight 测试数据库中保存匿名设备标识、记忆卡和复习记录。当前版本未提供账号或跨设备恢复;卸载 App 后,新安装生成的设备标识可能无法再次访问旧数据。 -用户可以在 App 的“我的”页面使用“删除我的数据”功能,删除当前匿名设备下保存的数据。删除后,当前设备身份下的记忆卡、复习记录、通知、生成任务和推送 token 会从可用数据中移除。服务端可能保留最小必要的删除审计记录,用于证明删除动作和排查异常。 - -如果数据已经进入数据库备份,备份中的历史数据会按照我们的备份保留和恢复策略在后续备份轮换中被覆盖。我们不会主动从备份中恢复用户已删除的数据,除非为处理严重事故且符合法律和安全要求。 - -## 生成额度和防滥用 - -Omo 当前可能对真实 AI 生成设置每日免费次数限制。额度记录用于判断当天是否还能继续生成,不用于广告追踪。 - -推荐好文等预置学习内容可能不计入用户真实 AI 生成额度,具体以 App 内实际提示为准。 +如需删除当前匿名设备标识关联的云端数据,请通过下方邮箱联系。处理删除请求时,我们可能需要用户提供能定位匿名设备数据的必要信息。 ## 通知 -如果用户允许系统通知,Omo 会使用 APNs token 发送卡片生成状态或复习提醒。用户可以随时在 iOS 系统设置中关闭通知。关闭通知不会影响 App 内查看记忆卡和通知列表。 - -## 追踪和广告 - -Omo 当前不接入广告 SDK,不进行跨 App 或跨网站追踪,不出售用户数据。 +如果用户允许通知,Omo 会在设备本地按复习时间安排提问通知。当前 TestFlight 版本不向服务端注册 APNs 推送令牌。用户可以随时在 iOS 系统设置中关闭通知。 ## 安全 -我们会采取合理措施保护用户数据,包括使用 HTTPS 传输数据、将模型 API Key 和生成逻辑保存在服务端、限制生成额度、记录必要审计事件,并尽量避免在日志中保存完整敏感内容。 +Omo 使用 HTTPS 传输 App 与测试服务之间的数据,模型密钥只保存在服务端,并将 TestFlight 数据与生产环境隔离。任何互联网服务都无法保证绝对安全;我们会持续减少收集范围并修复已知风险。 ## 联系方式 -如果你对隐私政策或数据删除有疑问,可以通过以下方式联系: - -邮箱:mingyuhan0814@gmail.com +隐私问题和数据删除请求:mingyuhan0814@gmail.com ## 政策更新 -随着账号、订阅、更多 AI 能力或更多云端能力上线,本隐私政策可能会更新。我们会在必要时通过 App 或相关页面提示重要变化。 +如果数据处理方式发生实质变化,我们会更新本政策,并在需要时重新取得用户许可。 diff --git a/docs/privacy-policy.html b/docs/privacy-policy.html index 0b920fc..e248fde 100644 --- a/docs/privacy-policy.html +++ b/docs/privacy-policy.html @@ -5,147 +5,60 @@ <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Omo 隐私政策 -
-
-

Omo 隐私政策

-

更新日期:2026 年 7 月 2 日

- -

Omo 是一款把用户主动保存的截图或平台内容转化为有证据绑定的记忆卡,并通过主动回忆和复习调度帮助用户再次想起内容的 iOS 应用。当前版本不强制注册账号,主要使用匿名设备身份保存当前设备下的数据。后续如果提供 Apple 登录、跨设备同步、订阅或更多云端能力,本政策会同步更新。

- -

我们收集哪些信息

-

为了提供生成和学习功能,Omo 会收集和保存以下信息:

-
    -
  • 用户主动提交的截图、平台链接、提取到的正文或字幕、标题、作者和来源信息。
  • -
  • 系统生成的记忆卡、题目变式、解释、证据片段、反馈和复习时间。
  • -
  • 学习记录,包括答题结果、已完成进度、当前继续学习位置、收藏、反馈和通知状态。
  • -
  • 匿名设备 ID,用于区分不同设备下的数据。
  • -
  • 如果用户允许系统通知,Omo 会保存当前设备的 APNs 推送 token,用于发送卡片生成状态或复习提醒。
  • -
  • 服务端诊断信息,例如生成失败类型、请求状态、队列状态和必要的错误日志,用于排查生成、通知和稳定性问题。
  • -
  • 生成额度使用记录,用于执行每日免费生成次数限制和防止滥用。
  • -
-

当前版本不主动收集真实姓名、手机号、通讯录、定位、相册、相机、麦克风、广告标识符或金融信息。

- -

我们如何使用这些信息

-

我们使用这些信息来:

-
    -
  • 恢复截图或平台内容中的来源、上下文和核心记忆点。
  • -
  • 生成一张有证据绑定的记忆卡、题目变式和解释。
  • -
  • 保存记忆卡、复习进度、通知和反馈。
  • -
  • 在内容生成完成或失败时发送系统通知提醒。
  • -
  • 执行每日免费生成额度,控制服务成本和异常滥用。
  • -
  • 改进生成质量、学习流程和产品稳定性。
  • -
  • 帮助用户在当前设备上继续未完成的学习。
  • -
-

我们不会把用户提交内容用于广告追踪,不会向第三方出售用户数据,也不会把用户提交内容公开展示给其他用户。

- -

第三方 AI 模型处理

-

为了理解截图并生成记忆卡,用户提交的图片、平台链接、提取到的正文或字幕和必要上下文可能会被发送给第三方 AI 模型服务进行处理。

-

Omo 会在用户首次进行真实 AI 生成前展示 AI 处理说明。用户可以选择同意后继续生成,也可以暂不生成。我们会尽量只发送完成生成所需的内容,并避免发送与生成无关的信息。

-

第三方 AI 模型服务可能根据其服务条款和隐私政策处理请求数据。

- -

匿名设备身份和账号

-

当前版本主要使用匿名设备 ID 区分用户数据,不要求注册或登录账号。匿名设备 ID 不是用户真实姓名或手机号,但它会用于关联当前设备下的记忆卡、复习记录、通知和生成额度。

-

匿名设备方案有明确边界:同一台设备、同一 App 身份上下文下,数据可以持续保存;如果用户删除 App、换机、重装后 Keychain 数据不可用,或安装到不同 App 身份上下文,历史数据可能无法自动恢复。

-

如果未来支持账号创建,App 内会提供账号删除入口。删除账号会删除账号及其关联数据,除非法律或安全原因要求保留最小必要记录。

- -

数据保存和删除

-

Omo 会在云端保存当前匿名设备下的记忆卡、题目变式、复习记录、通知、生成任务和必要诊断记录。

-

用户可以在 App 的“我的”页面使用“删除我的数据”功能,删除当前匿名设备下保存的数据。删除后,当前设备身份下的记忆卡、复习记录、通知、生成任务和推送 token 会从可用数据中移除。服务端可能保留最小必要的删除审计记录,用于证明删除动作和排查异常。

-

如果数据已经进入数据库备份,备份中的历史数据会按照我们的备份保留和恢复策略在后续备份轮换中被覆盖。我们不会主动从备份中恢复用户已删除的数据,除非为处理严重事故且符合法律和安全要求。

- -

生成额度和防滥用

-

Omo 当前可能对真实 AI 生成设置每日免费次数限制。额度记录用于判断当天是否还能继续生成,不用于广告追踪。

-

推荐好文等预置学习内容可能不计入用户真实 AI 生成额度,具体以 App 内实际提示为准。

- -

通知

-

如果用户允许系统通知,Omo 会使用 APNs token 发送卡片生成状态或复习提醒。用户可以随时在 iOS 系统设置中关闭通知。关闭通知不会影响 App 内查看记忆卡和通知列表。

- -

追踪和广告

-

Omo 当前不接入广告 SDK,不进行跨 App 或跨网站追踪,不出售用户数据。

- -

安全

-

我们会采取合理措施保护用户数据,包括使用 HTTPS 传输数据、将模型 API Key 和生成逻辑保存在服务端、限制生成额度、记录必要审计事件,并尽量避免在日志中保存完整敏感内容。

- -

联系方式

-

如果你对隐私政策或数据删除有疑问,可以通过以下方式联系:

-

邮箱:mingyuhan0814@gmail.com

- -

政策更新

-

随着账号、订阅、更多 AI 能力或更多云端能力上线,本隐私政策可能会更新。我们会在必要时通过 App 或相关页面提示重要变化。

-
-
+
+

Omo 隐私政策

+

更新日期:2026 年 8 月 8 日

+

Omo 把用户主动选择的截图转化为记忆卡,并通过主动回忆帮助用户再次想起内容。当前 TestFlight 版本不要求注册账号,使用随机生成的匿名设备标识区分不同设备的数据。

+ +

我们处理和保存的信息

+
    +
  • 用户主动选择的截图。Omo 测试服务会临时保存压缩截图,以便在网络波动或服务重启后继续完成 AI 识别和生成;截图任务成功或失败后,服务端截图副本会被删除。iOS 设备在任务成功后删除本地重试副本;失败时会保留该副本供用户重试。
  • +
  • 生成后的核心知识、承重语义、回忆问题、解释和来源信息。
  • +
  • 三档自评、复习次数和下次复习时间。
  • +
  • 用于关联当前设备数据的随机匿名设备标识。
  • +
  • 文字搜索词,以及语音搜索经 Apple 语音识别得到的文字。搜索词当前不保存到 Omo 数据库。
  • +
+

当前版本不要求真实姓名、手机号、邮箱、通讯录、定位或广告标识符,不包含广告 SDK,也不进行跨 App 或网站追踪。

+ +

使用目的

+

上述信息只用于生成、保存、检索和复习记忆卡,以及维持服务的安全与可用性。Omo 不出售用户数据,也不会公开展示用户提交的内容。

+ +

第三方 AI 与用户许可

+

必要的截图、搜索词或卡片文本会被发送给第三方 AI 服务,用于理解截图、生成记忆卡和语义搜索。Omo 会在首次发送截图前说明处理方式并取得许可;用户可以取消,也可以在 App 的“Settings”中关闭后续新截图的 AI 处理许可。

+

请不要上传含密码、证件号码、金融信息、医疗信息或其他敏感个人信息的截图。第三方 AI 服务会依据其服务协议和数据处理约定处理请求。

+ +

语音输入

+

语音搜索使用 Apple 的语音识别能力将语音转成文字。Omo 只把识别后的搜索文字发送给自己的测试服务,不在自己的服务器保存原始录音。用户也可以完全使用文字搜索。

+ +

数据保存和删除

+

Omo 在隔离的 TestFlight 测试数据库中保存匿名设备标识、记忆卡和复习记录。当前版本未提供账号或跨设备恢复;卸载 App 后,新安装生成的设备标识可能无法再次访问旧数据。

+

如需删除当前匿名设备标识关联的云端数据,请通过下方邮箱联系。处理删除请求时,我们可能需要用户提供能定位匿名设备数据的必要信息。

+ +

通知

+

如果用户允许通知,Omo 会在设备本地按复习时间安排提问通知。当前 TestFlight 版本不向服务端注册 APNs 推送令牌。用户可以随时在 iOS 系统设置中关闭通知。

+ +

安全

+

Omo 使用 HTTPS 传输 App 与测试服务之间的数据,模型密钥只保存在服务端,并将 TestFlight 数据与生产环境隔离。任何互联网服务都无法保证绝对安全;我们会持续减少收集范围并修复已知风险。

+ +

联系方式

+

隐私问题和数据删除请求:mingyuhan0814@gmail.com

+ +

政策更新

+

如果数据处理方式发生实质变化,我们会更新本政策,并在需要时重新取得用户许可。

+
diff --git a/docs/quality-baseline.md b/docs/quality-baseline.md index f7d8a40..8610046 100644 --- a/docs/quality-baseline.md +++ b/docs/quality-baseline.md @@ -49,6 +49,14 @@ npm --prefix backend run check npm --prefix backend run test:all ``` +PostgreSQL / migration 变化追加: + +```bash +npm --prefix backend run test:postgres +``` + +该命令必须使用临时或明确隔离的本地数据库与合成数据;不得把生产 `DATABASE_URL` 传给测试 harness。 + 按范围追加: ```bash @@ -75,10 +83,12 @@ npm --prefix backend run smoke:v2:queue 数据库或 worker 变化必须: - 新增顺序 migration,不改写已发布 migration。 +- migration runner 必须检查版本与 checksum,并在并发执行时串行化;服务启动不得静默自动迁移生产库。 - 在空数据库和从上一版本升级两条路径运行。 - 验证重复投递、进程重启、锁竞争、删除竞争和回滚/停止策略。 - 把本地内存 fallback 与 Postgres 结果分开报告。 - 没有真实 Postgres 证据时不得声称持久任务已生产可用。 +- JSON 导入默认 dry-run;真实数据导入、生产 migration、恢复和连接切换必须使用 manual Plan 与人工授权。 ## iOS 门禁 diff --git a/docs/staging-testflight-runbook.md b/docs/staging-testflight-runbook.md new file mode 100644 index 0000000..5ea3233 --- /dev/null +++ b/docs/staging-testflight-runbook.md @@ -0,0 +1,134 @@ +# Omo TestFlight Staging 运行手册 + +## 安全边界 + +- 仅使用 Railway 项目 `Omo TestFlight Staging` 的 `staging` 环境。 +- 禁止 link、读取、改变量、迁移或部署现有项目“拾贝”。 +- 禁止向 `main` push;本轮 TestFlight 迭代只来自 `codex/omo-independent-app`。 +- staging 使用独立 Postgres,不导入、复制或查询生产数据。 +- 密钥只保存在 Railway 变量或 App Store Connect,不写入仓库、命令输出、日志或验收截图。 + +## 部署前目标校验 + +每次 Railway 操作前都运行: + +```sh +railway status --json +railway environment list --json +``` + +只有同时满足以下条件才能继续: + +- 项目名为 `Omo TestFlight Staging`。 +- `staging` 的 `isLinked` 为 `true`。 +- 目标服务只能是 `omo-api-staging` 或同项目的 `Postgres`。 + +自动化命令还应显式传入 `--project` / `--environment staging` / `--service`,不依赖交互式选择。 + +## staging 变量合同 + +`omo-api-staging` 必须配置: + +| 变量 | 要求 | +| --- | --- | +| `NODE_ENV` | `production`,用于启用 fail-closed 门禁 | +| `HOST` | `0.0.0.0` | +| `OMO_DEMO_MODE` | `0` | +| `STORE_DRIVER` | 必须显式为 `postgres` | +| `DATABASE_URL` | `${{Postgres.DATABASE_URL}}`,仅引用同项目独立数据库 | +| `QWEN_API` | staging 专用密钥,不得从生产项目读取或复制 | +| `QWEN_BASE_URL` | DashScope compatible-mode HTTPS URL | +| `QWEN_MODEL` | 当前合同为 `qwen3-vl-plus` | +| `TIKHUB_API_KEY` | staging 专用密钥 | +| `TIKHUB_BASE_URL` | `https://api.tikhub.io` | + +`NODE_ENV=production` 表示服务启用发布门禁,不表示连接 Omo 生产项目。项目和数据边界仍由上述独立 staging 资源确定。 + +## Migration + +Migration 不在进程启动时自动执行。部署前必须先运行只读状态检查,再显式执行,最后复查版本与 checksum。 + +当本机不能解析 Railway 私网域名时,可仅对 staging Postgres 临时创建密码保护的 TCP proxy,运行检查和 migration 后立即删除,并确认 `tcp-proxy list` 为空。不得对生产数据库使用此流程。 + +## 部署与验证顺序 + +1. 运行后端 `check`、`test:all` 和 `docs:check`。 +2. 确认 migration status 为 ready,pending 为空。 +3. 确认 `QWEN_API` 与 `TIKHUB_API_KEY` 在 staging 中存在,只输出键名不输出值。 +4. 从当前分支部署 `omo-api-staging`,再创建公网 HTTPS domain。 +5. 验证 `/api/health` 为 200,`/api/readiness` 为 200,且 storage 显示 PostgreSQL 001/002 已应用。 +6. 用全新匿名设备 ID 走空库、授权截图生成、读取、搜索、assessment 幂等、重启回读和删除。 +7. 将验证过的 staging HTTPS URL 注入 Release/TestFlight 构建,不提供生产 URL 回退。 + +## Archive 与内部 TestFlight 导出 + +只有 staging backend 完整闭环通过后才能执行本节。`STAGING_API_URL` 必须来自上述新项目,禁止填写现有生产 URL;`BUILD_NUMBER` 必须先通过 App Store Connect 查询,取该 App 已有最大 build number 加一。 + +```sh +mkdir -p .release + +xcodebuild archive \ + -project Omo/Omo.xcodeproj \ + -scheme Omo \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath .release/Omo.xcarchive \ + -allowProvisioningUpdates \ + OMO_API_BASE_URL="$STAGING_API_URL" \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" + +xcodebuild -exportArchive \ + -archivePath .release/Omo.xcarchive \ + -exportPath .release/export \ + -exportOptionsPlist config/ExportOptions-TestFlight.plist \ + -allowProvisioningUpdates +``` + +导出配置使用 `destination=export`,因此不会在导出时意外上传;上传必须是后续显式动作。`testFlightInternalTestingOnly=false` 保证构建可送交 Beta App Review 并加入外部测试组;它不会因此自动发布到 App Store。`manageAppVersionAndBuildNumber=false` 保证 Xcode 不静默改写已审计的 build number。 + +导出后必须再次检查: + +- Bundle ID 为独立 Omo 身份 `com.maxhan.omo`,不得使用旧 Recaro/Recallo 的 `com.maxhan.shibei`;版本号与新 Omo App 的远端 build number 不冲突。 +- App 包含 `PrivacyInfo.xcprivacy`,`ITSAppUsesNonExemptEncryption=false`。 +- 二进制不包含 localhost、Debug Fixture 启动参数、旧生产域名或 Mock 成功路径。 +- `OmoAPIBaseURL` 是已验证的 staging HTTPS URL,且 `/api/readiness` 为 200。 +- 只有上述门禁全部通过,才使用已认证的 `asc` 显式上传 IPA,并关联内部测试组。 + +## TestFlight“测试内容”草案 + +> 请只使用你自己的非敏感截图。首次上传会询问是否允许 AI 处理截图。 +> +> 本轮请重点测试:从相册上传截图并生成知识卡;点击首页 Omo 开始最多十张回顾;刮开关键词后完成三档自评;从知识库浏览、搜索和查看完整上下文;允许通知后,从具体问题通知进入对应回顾卡。 +> +> 如遇生成、搜索或自评失败,请保留截图与大致发生时间,并联系测试支持邮箱。测试数据仅写入隔离 staging,不与生产数据互通。 + +## 当前剩余人工确认 + +1. Omo 已使用商店名称 `Omo(哦莫)` 创建为独立 App;安装后的 `CFBundleDisplayName` 仍为 `Omo`。 +2. 新 Omo 内部测试组没有继承旧 App 测试员;产品方需确认首批测试员范围后再邀请。 +3. 首位测试员在自己的真机上复核相册、麦克风、语音识别、通知权限和 VoiceOver;未经明确授权,不由自动化代操作用户设备。 +4. 正式公开发布前再次确认公开支持邮箱。 + +API 私钥不得提交到 Git;只保存在本机安全凭据存储。 + +## 当前状态(2026-08-08) + +- 新项目、`staging` 环境、`omo-api-staging` 和独立 Postgres 已创建。 +- Migration `001` / `002` / `003` 已应用并验证 ready;`003` 建立可恢复的截图任务表。 +- 临时 Postgres TCP proxy 已删除,当前 proxy 列表为空。 +- staging 变量已配置;供应商密钥只存在 Railway staging,未写入仓库或日志。 +- backend 核心交互修复部署 `f8c51bc7-0d69-42d6-b8b6-15909c604822` 为 `SUCCESS`,公网域名为 `https://omo-api-staging-staging.up.railway.app`;pre-deploy 会先执行 migration,再进行 readiness 检查。 +- health/readiness、空库、真实生成、读取、搜索、assessment 幂等、重启回读和删除均已通过;合成测试数据已删除。 +- Release 默认连接上述 staging HTTPS 域名;当前独立 Bundle `com.maxhan.omo` 的 Simulator XCTest(含 UI Tests)49/49 通过。 +- `asc 3.5.1` 已使用系统 Keychain profile 完成认证并通过在线验证;私钥和凭据未进入仓库。 +- 新 Apple Distribution 证书和 App Store provisioning profile 均有效至 2027-08-08。 +- 工程最低系统已正式设为 iOS 17.0;签名 Archive 与导出 IPA 均通过。 +- 旧 App 下错误上传的 `1.0 (28)` 已从旧测试组移除并永久设为 `EXPIRED`;它不能作为 Omo 发布构建。 +- 错误 Draft PR #38 已关闭。旧 App build 1–27、测试员和生产数据均未修改。 +- 全新 Bundle ID `com.maxhan.omo` 已注册且此前没有绑定 App;主工程已切换到该身份并将新 App 构建号重置为 1。 +- `com.maxhan.omo` 的独立 App Store profile、签名 `1.0 (1)` Archive 和 IPA 已生成并通过包审计;同一 IPA 已上传至新的 Omo App。 +- 当前独立 Bundle `com.maxhan.omo` 已实际执行全部 Simulator XCTest:49 通过、0 失败、0 跳过;其中包含 5 条核心交互 UI Test。 +- 新 Omo App Store Connect App ID 为 `6799407458`;独立 build 1(Build ID `7bb306b6-dc29-43ca-9547-782ae6fa2009`)状态为 `VALID`。 +- 核心交互修复使用独立 build 2(Build ID `a387a603-dc77-4dda-b0a3-862e1d490936`):Archive、App Store 导出、包内 Bundle / 版本 / staging 地址、正式签名均已校验,App Store Connect 状态为 `VALID` / `IN_BETA_TESTING`。 +- 独立内部组 ID 为 `109f9f4f-75b9-4419-be1f-46edd4bc016a`;build 2 已加入该组并开启自动通知,组内当前一名 Omo 测试员状态为 `INSTALLED`。不得复用旧 App 记录、旧测试组或旧测试员。 +- 隐私政策与支持页已分别托管在 `/privacy` 和 `/support`;兼容地址 `/privacy-policy.html` 亦可用。 diff --git a/docs/superpowers/plans/2026-08-02-active-recall-selective-reintegration.md b/docs/superpowers/plans/2026-08-02-active-recall-selective-reintegration.md new file mode 100644 index 0000000..23efb72 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-active-recall-selective-reintegration.md @@ -0,0 +1,507 @@ +# Active Recall Selective Reintegration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore the approved Omo Figma home and active-recall interaction on top of `omo/main@cb23265` without losing the team's runtime safety, Profile, persistence, tests, or collaboration work. + +**Architecture:** Keep the current backend routes, `runtimeConfig`, store, `OmoStore`, and `APIClient` as the system boundaries. Extend the card contract with a validated `hiddenSemantic`, then compose the restored home from focused SwiftUI views whose pure round state is independently tested. Integrate existing Library, Profile, Settings, and photo upload as sheets or side-menu destinations so the home remains the single recall scene. + +**Tech Stack:** Swift 5 / SwiftUI / XCTest / iOS Simulator, Node.js ESM / `node:test`, Xcode project, GitHub CLI. + +--- + +## File map + +### Backend + +- Modify `backend/src/cardService.js`: generate, validate, repair, and persist `hiddenSemantic` while continuing to consume `runtimeConfig`. +- Modify `backend/test/cardService.test.js`: cover exact-substring validation, one repair, second failure, Fixture, screenshot-only rarity, and existing error privacy. +- Modify `backend/test/server.test.js`: assert the new field survives the HTTP route and invalid model output remains a sanitized 502. + +### iOS model and state + +- Modify `Omo/Omo/Models/OmoModels.swift`: optional decoding, exact segmentation, and recall eligibility. +- Modify `Omo/Omo/OmoStore.swift`: filter the draw pool to due and eligible cards and support a frozen maximum-ten-card round. +- Modify `Omo/OmoTests/APIClientDecodingTests.swift`: old-card and new-card decoding compatibility. +- Create `Omo/Omo/RecallInteractionState.swift`: pure reveal, submission, retry, and slider node rules. +- Create `Omo/OmoTests/RecallInteractionStateTests.swift`: boundary and transition tests. + +### iOS visual interaction + +- Create `Omo/Omo/RecallDesign.swift`: restored palette, typography, sizing, and fixed slider-gradient tokens. +- Create `Omo/Omo/RecallHomeView.swift`: Figma home, persistent folder/upload controls, summon state, and routing callbacks. +- Create `Omo/Omo/RecallRoundView.swift`: frozen four-layer deck and card-to-card orchestration. +- Create `Omo/Omo/RecallKnowledgeCardView.swift`: inline semantic masking, 80% reveal, details Sheet, and accessibility boundary. +- Create `Omo/Omo/RecallRatingSlider.swift`: four positions, fixed full-track gradient, haptics, cancel, submit, and retry. +- Modify `Omo/Omo/ContentView.swift`: replace the competing bottom-tab recall flow with the restored home and side-menu routing while retaining current Library/upload views and `ProfileView`. +- Modify `Omo/Omo.xcodeproj/project.pbxproj`: add the new Swift sources and tests to their targets if the project does not auto-discover them. +- Restore the `FirstLaunchArrow`, `FirstLaunchFolder`, `FirstLaunchMenu`, `FirstLaunchPanel`, and `FirstLaunchUpload` asset sets from `009e943` under `Omo/Omo/Assets.xcassets/`. + +### Documentation and evidence + +- Modify `docs/ios-api-data-contract-zh.md`: document `hiddenSemantic`, compatibility, and generation failure semantics. +- Modify `docs/frontend/v2-frontend-architecture.md`: document the home-scene composition and focused recall views. +- Modify `docs/frontend/v2-layout-system.md`: document the exact slider/reveal visual contract. +- Create `artifacts/active-recall-reintegration/README.md`: index Simulator screenshots and validation commands without committing user data. + +## Task 1: Restore the backend hidden-semantic contract + +**Files:** +- Modify: `backend/test/cardService.test.js` +- Modify: `backend/src/cardService.js` +- Test: `backend/test/cardService.test.js` + +- [ ] **Step 1: Add failing contract tests** + +Add a `generated()` fixture whose `coreKnowledge` contains `hiddenSemantic`, then assert: + +```js +assert.equal(card.hiddenSemantic, "认知卸载"); +assert.equal(card.answer, card.hiddenSemantic); +assert.ok(card.coreKnowledge.includes(card.hiddenSemantic)); +``` + +Add a fetch stub that returns an invalid first result and valid second result. Assert two Qwen requests were made and the second prompt contains the validation error. Add a second-invalid test that expects `statusCode === 502`, `code === "model_invalid_response"`, and no source verification call. + +- [ ] **Step 2: Verify the new tests fail for the intended reason** + +Run: + +```bash +npm --prefix backend test -- --test-name-pattern='hidden semantic|repairs one invalid' +``` + +Expected: failures because the current card has no `hiddenSemantic` and does not retry invalid output. + +- [ ] **Step 3: Implement validation and one repair through current runtime configuration** + +Export an exact validator: + +```js +export function hasValidHiddenSemantic(value) { + const coreKnowledge = text(value?.coreKnowledge); + const hiddenSemantic = text(value?.hiddenSemantic); + return hiddenSemantic.length > 0 && coreKnowledge.includes(hiddenSemantic); +} +``` + +Change Qwen generation to accept a `mode`, include `hiddenSemantic` in the schema prompt, and on the first invalid candidate make exactly one additional request using the same image plus a sanitized validation reason. Keep `config.qwen.baseURL`, API key, model, timeout, stable network error mapping, and `AbortSignal.timeout` from the current implementation. + +After the second invalid candidate, throw: + +```js +throw httpError( + 502, + "model_invalid_response", + "视觉模型返回的承重语义无法验证。" +); +``` + +Construct the returned card with: + +```js +hiddenSemantic, +answer: hiddenSemantic, +rarity: source.status === "verified" && validRarities.has(generated.rarity) + ? generated.rarity + : "R" +``` + +Update `demoCard()` so `hiddenSemantic: "再次想起"` is an exact substring of its `coreKnowledge`. Do not alter Demo-mode gating, readiness, or production failure behavior. + +- [ ] **Step 4: Run backend tests** + +Run: + +```bash +npm --prefix backend test +``` + +Expected: all existing and new backend tests pass; no production/runtimeConfig test is removed. + +- [ ] **Step 5: Commit the backend contract** + +```bash +git add backend/src/cardService.js backend/test/cardService.test.js backend/test/server.test.js +git commit -m "feat: restore validated hidden semantic cards" +``` + +## Task 2: Add compatible iOS modeling and pure recall state + +**Files:** +- Modify: `Omo/Omo/Models/OmoModels.swift` +- Modify: `Omo/Omo/OmoStore.swift` +- Modify: `Omo/OmoTests/APIClientDecodingTests.swift` +- Create: `Omo/Omo/RecallInteractionState.swift` +- Create: `Omo/OmoTests/RecallInteractionStateTests.swift` + +- [ ] **Step 1: Write failing model and state tests** + +Decode one JSON card containing `hiddenSemantic` and one legacy card without it. Assert the first is recall eligible and segments into the first exact match, while the second remains decodable but ineligible. + +Add pure state tests for: + +```swift +state.updateCoverage(0.79) // showsRating == false +state.updateCoverage(0.80) // coverage == 1, showsRating == true +``` + +Also assert successful submission advances and reseals, failure preserves the index and retries the same assessment, and slider positions resolve to cancel / forgot / fuzzy / remembered at `0.00 / 0.42 / 0.70 / 0.97`. + +- [ ] **Step 2: Verify the tests fail** + +Run: + +```bash +xcodebuild test -project Omo/Omo.xcodeproj -scheme Omo -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:OmoTests/APIClientDecodingTests -only-testing:OmoTests/RecallInteractionStateTests +``` + +Expected: compile/test failure because the new model property and state types do not yet exist. + +- [ ] **Step 3: Add exact segmentation and eligibility** + +Add `let hiddenSemantic: String?` to `MemoryCard` and: + +```swift +struct RecallKnowledgeSegments: Equatable { + let prefix: String + let semantic: String + let suffix: String + + static func make(coreKnowledge: String, hiddenSemantic: String?) -> Self? { + let semantic = hiddenSemantic?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !semantic.isEmpty, let range = coreKnowledge.range(of: semantic) else { return nil } + return Self( + prefix: String(coreKnowledge[.. **For agentic workers:** REQUIRED SUB-SKILL: execute this plan task-by-task with test-first checkpoints. The repository does not expose `superpowers:executing-plans` in this session, so the primary agent executes inline and records each checkpoint in the active repository plan. + +**Goal:** Build the Figma-designed Omo knowledge library with complete-card browsing, measured masonry pagination, text and speech input, replaceable mock search, complete UI states, tests, and Simulator evidence. + +**Architecture:** Extract the current private list into a focused `KnowledgeLibraryView` feature. A `@MainActor` view model owns a cancel-safe search state machine and speech orchestration behind protocols; a Debug fixture searcher supplies deterministic results without pretending to be production vector search. SwiftUI measures the rendered cards at the actual width and Dynamic Type size, then a pure paginator distributes those measured heights across two columns and horizontal pages. + +**Tech Stack:** Swift 5, SwiftUI, Observation via `ObservableObject`, Speech, AVFoundation, XCTest, Xcode 26 / iOS 26 Simulator, repository Markdown contracts. + +--- + +## File map + +- Create `Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySearch.swift`: request/result types, state enum, search protocol, deterministic Debug mock, unavailable production adapter. +- Create `Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySpeech.swift`: speech protocol, state, Apple Speech/AVAudio implementation, Debug transcript implementation. +- Create `Omo/Omo/KnowledgeLibrary/KnowledgeLibraryViewModel.swift`: query, request cancellation, result mapping, speech/search state transitions. +- Create `Omo/Omo/KnowledgeLibrary/KnowledgeLibraryPagination.swift`: pure measured-height pagination and page models. +- Create `Omo/Omo/KnowledgeLibrary/KnowledgeLibraryView.swift`: screen shell, search bar, state views, pager, card, page indicator, detail/upload hooks. +- Create `Omo/Omo/KnowledgeLibrary/KnowledgeLibraryDebugFixtures.swift`: synthetic cards and Debug-only launch argument parsing. +- Create `Omo/OmoTests/KnowledgeLibrarySearchTests.swift`: view-model state, races, mapping, speech handoff. +- Create `Omo/OmoTests/KnowledgeLibraryPaginationTests.swift`: order and measured-height page breaking. +- Modify `Omo/Omo/ContentView.swift`: remove the old private list/row and mount the feature with store callbacks. +- Modify `Omo/Omo/OmoStore.swift`: Debug-only synthetic library state injection without altering production load behavior. +- Modify `Omo/Omo/Models/OmoModels.swift`: `Sendable` conformance needed by structured concurrency. +- Modify `Omo/Omo/RecallDesign.swift`: named library tokens and component/screen metrics. +- Modify `Omo/Omo.xcodeproj/project.pbxproj`: Speech/Microphone generated Info.plist usage descriptions only; synchronized groups pick up new source files. +- Modify `Omo/Omo/Localizable.xcstrings`, `Omo/Omo/en.lproj/InfoPlist.strings`, `Omo/Omo/zh-Hans.lproj/InfoPlist.strings`: visible and permission copy. +- Modify `docs/knowledge-library-prd.md`, `docs/index.md`, `docs/frontend/v2-frontend-architecture.md`, `docs/frontend/v2-layout-system.md`, `docs/ios-api-data-contract-zh.md`, `docs/asset-provenance.md`, `docs/quality-baseline.md`: stable product, contract, layout, asset and validation facts. +- Modify `plans/codex-knowledge-library-search.md`, `PLANS.md`: progress and final evidence lifecycle. + +### Task 1: Stabilize the product contract and search state with tests + +**Files:** +- Create: `docs/knowledge-library-prd.md` +- Modify: `docs/index.md` +- Create: `Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySearch.swift` +- Create: `Omo/Omo/KnowledgeLibrary/KnowledgeLibraryViewModel.swift` +- Test: `Omo/OmoTests/KnowledgeLibrarySearchTests.swift` + +- [ ] **Step 1: Write the PRD before implementation** + +Record the confirmed user story, default complete-card browsing, text/voice flows, horizontal paging, state table, non-goals, analytics names without an analytics SDK, accessibility, privacy, mock boundary, future backend contract, and acceptance matrix. Explicitly state: current build uses a Debug/test search adapter and does not prove production vector retrieval. + +- [ ] **Step 2: Add failing view-model tests** + +Use a controllable actor-backed spy: + +```swift +actor SearchSpy: KnowledgeLibrarySearching { + var continuations: [String: CheckedContinuation] = [:] + + func search(_ request: KnowledgeLibrarySearchRequest) async throws -> KnowledgeLibrarySearchResponse { + try await withCheckedThrowingContinuation { continuation in + continuations[request.query] = continuation + } + } + + func succeed(_ query: String, ids: [String]) { + continuations.removeValue(forKey: query)?.resume( + returning: .init(orderedCardIDs: ids) + ) + } +} +``` + +Tests must assert: + +```swift +@MainActor +func testBlankQueryRestoresAllCardsAndCancelsSearch() async + +@MainActor +func testLatestRequestWinsWhenOlderResponseFinishesLast() async + +@MainActor +func testResultMappingDropsUnknownAndDuplicateIDs() async + +@MainActor +func testEmptyResponseAndFailureRemainDistinct() async + +@MainActor +func testRetryPreservesQuery() async +``` + +- [ ] **Step 3: Run tests and prove the new types are missing** + +Run: + +```bash +xcodebuild -project Omo/Omo.xcodeproj -scheme Omo -showdestinations +xcodebuild test -project Omo/Omo.xcodeproj -scheme Omo -destination 'platform=iOS Simulator,id=' -only-testing:OmoTests/KnowledgeLibrarySearchTests +``` + +Expected: compile failure because `KnowledgeLibrarySearching` and `KnowledgeLibraryViewModel` do not exist. + +- [ ] **Step 4: Implement the minimal search boundary and state machine** + +Use production-shaped types with explicit candidate documents so the local mock can work without reaching into UI state: + +```swift +struct KnowledgeLibrarySearchDocument: Equatable, Sendable { + let id: String + let coreKnowledge: String + let recallCue: String + let explanation: String + let sourceTitle: String +} + +struct KnowledgeLibrarySearchRequest: Equatable, Sendable { + let query: String + let candidates: [KnowledgeLibrarySearchDocument] +} + +struct KnowledgeLibrarySearchResponse: Equatable, Sendable { + let orderedCardIDs: [String] +} + +protocol KnowledgeLibrarySearching: Sendable { + func search(_ request: KnowledgeLibrarySearchRequest) async throws + -> KnowledgeLibrarySearchResponse +} + +enum KnowledgeLibraryResultsState: Equatable { + case all + case searching + case results + case noResults + case failed(message: String) +} +``` + +The view model owns `query`, `state`, `visibleCards`, `currentPage`, and one `Task`. `submit()` trims query, cancels the previous task, captures a UUID generation, awaits search, filters response IDs through a dictionary of current cards, preserves response order, de-duplicates IDs, and only commits if generation is still current. `clearQuery()` cancels and restores `.all`. `updateCards(_:)` removes deleted results and restores all when the query is empty. + +- [ ] **Step 5: Run the search tests** + +Run the same `xcodebuild test` command. Expected: all `KnowledgeLibrarySearchTests` pass. + +- [ ] **Step 6: Commit the contract and state layer** + +```bash +git add docs/knowledge-library-prd.md docs/index.md Omo/Omo/KnowledgeLibrary Omo/OmoTests/KnowledgeLibrarySearchTests.swift plans/codex-knowledge-library-search.md +git commit -m "feat: add knowledge library search state" +``` + +### Task 2: Implement measured masonry pagination test-first + +**Files:** +- Create: `Omo/Omo/KnowledgeLibrary/KnowledgeLibraryPagination.swift` +- Test: `Omo/OmoTests/KnowledgeLibraryPaginationTests.swift` + +- [ ] **Step 1: Write failing pure paginator tests** + +Use fixed item IDs and measured heights: + +```swift +func testPaginatorFillsShorterColumnWithoutReordering() +func testPaginatorStartsNewPageBeforeEitherColumnOverflows() +func testSingleOversizedCardStillGetsItsOwnPage() +func testLargerMeasuredHeightsProduceMorePages() +func testEmptyInputProducesNoPages() +``` + +Assert both visual placement and logical reading order. Example: + +```swift +let pages = paginator.pages( + itemHeights: [("a", 120), ("b", 180), ("c", 100), ("d", 160)], + availableHeight: 300, + verticalSpacing: 18 +) +XCTAssertEqual(pages.flatMap(\.readingOrder), ["a", "b", "c", "d"]) +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run `-only-testing:OmoTests/KnowledgeLibraryPaginationTests`. Expected: compile failure because paginator types do not exist. + +- [ ] **Step 3: Implement the pure paginator** + +Define: + +```swift +struct KnowledgeLibraryPage: Equatable, Sendable { + struct Placement: Equatable, Sendable { + let id: ID + let column: Int + let y: CGFloat + let height: CGFloat + let sourceIndex: Int + } + + let placements: [Placement] + var readingOrder: [ID] { + placements.sorted { $0.sourceIndex < $1.sourceIndex }.map(\.id) + } +} +``` + +Greedily place each measured item in the shorter column. If the selected column would exceed `availableHeight` and the current page is non-empty, close the page and place the item at `y = 0` on a new page. Preserve `sourceIndex` for accessibility order. Clamp invalid/negative heights to zero and guarantee an oversized card is never dropped. + +- [ ] **Step 4: Run pagination and full unit tests** + +Expected: pagination suite and existing `OmoTests` pass. + +- [ ] **Step 5: Commit** + +```bash +git add Omo/Omo/KnowledgeLibrary/KnowledgeLibraryPagination.swift Omo/OmoTests/KnowledgeLibraryPaginationTests.swift plans/codex-knowledge-library-search.md +git commit -m "feat: add measured knowledge card pagination" +``` + +### Task 3: Add real speech boundaries and Debug speech simulation + +**Files:** +- Create: `Omo/Omo/KnowledgeLibrary/KnowledgeLibrarySpeech.swift` +- Modify: `Omo/Omo/KnowledgeLibrary/KnowledgeLibraryViewModel.swift` +- Modify: `Omo/Omo.xcodeproj/project.pbxproj` +- Modify: `Omo/Omo/en.lproj/InfoPlist.strings` +- Modify: `Omo/Omo/zh-Hans.lproj/InfoPlist.strings` +- Test: `Omo/OmoTests/KnowledgeLibrarySearchTests.swift` + +- [ ] **Step 1: Add failing speech handoff tests** + +Implement a spy transcriber that emits states through an `AsyncStream` and test: + +```swift +@MainActor +func testFinalVoiceTranscriptUpdatesQueryAndSubmitsExactlyOnce() async + +@MainActor +func testVoicePermissionFailureDoesNotEraseTypedQuery() async + +@MainActor +func testClearAndDisappearStopListening() async +``` + +- [ ] **Step 2: Run and verify failure** + +Expected: missing `KnowledgeLibrarySpeechTranscribing` and speech state. + +- [ ] **Step 3: Implement protocol and production controller** + +Define: + +```swift +enum KnowledgeLibrarySpeechEvent: Equatable, Sendable { + case listening + case transcript(String, isFinal: Bool) + case denied + case unavailable + case failed(String) + case stopped +} + +protocol KnowledgeLibrarySpeechTranscribing: AnyObject { + var events: AsyncStream { get } + func start() async + func stop() +} +``` + +The Apple implementation wraps `SFSpeechRecognizer(locale: Locale(identifier: "zh-CN"))`, `SFSpeechAudioBufferRecognitionRequest`, and `AVAudioEngine`. It requests both authorization boundaries, installs one input tap, yields partial transcripts, removes the tap in every stop/error path, and never writes audio to disk. A Debug implementation yields a configured final transcript after a short deterministic delay. + +Add generated Info.plist keys: + +```text +INFOPLIST_KEY_NSMicrophoneUsageDescription = "用语音描述你想找的知识。" +INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "把你的语音转成知识库搜索文字。" +``` + +Localize the permission copy in `InfoPlist.strings`. + +- [ ] **Step 4: Connect speech events to the view model** + +`startOrStopVoice()` toggles listening. Partial transcripts update the query. A non-empty final transcript calls `submit()` once. Denied/unavailable/failed states preserve typed text and expose a recoverable message. `onDisappear()` cancels search and stops speech. + +- [ ] **Step 5: Run tests and Debug build** + +Expected: speech tests pass; Debug build links Speech/AVFoundation without privacy-key warnings. + +- [ ] **Step 6: Commit** + +```bash +git add Omo/Omo/KnowledgeLibrary Omo/OmoTests/KnowledgeLibrarySearchTests.swift Omo/Omo.xcodeproj/project.pbxproj Omo/Omo/*lproj/InfoPlist.strings plans/codex-knowledge-library-search.md +git commit -m "feat: add knowledge library voice input" +``` + +### Task 4: Build the Figma screen from tokens to components to page + +**Files:** +- Modify: `Omo/Omo/RecallDesign.swift` +- Create: `Omo/Omo/KnowledgeLibrary/KnowledgeLibraryView.swift` +- Modify: `Omo/Omo/ContentView.swift` +- Modify/Create: exact asset files under `Omo/Omo/Assets.xcassets/` +- Modify: `docs/asset-provenance.md` +- Modify: `Omo/Omo/Localizable.xcstrings` + +- [ ] **Step 1: Audit and import only exact assets** + +Reuse existing FirstLaunch/Recall assets where their hashes and artwork match Figma. Import the supplied microphone and back SVG only if the existing asset is not exact. Record source URL/node, user attachment path, purpose, transformation and SHA-256 in `docs/asset-provenance.md`. Do not redraw raster artwork or replace it with an SF Symbol. + +- [ ] **Step 2: Add library tokens and metrics** + +Extend `RecallDesign.swift` with: + +```swift +enum KnowledgeLibraryMetrics { + static let referenceSize = CGSize(width: 402, height: 874) + static let backFrame = CGRect(x: 21, y: 43, width: 70, height: 70) + static let mascotFrame = CGRect(x: 222, y: 48, width: 157, height: 157) + static let panelTop: CGFloat = 180 + static let searchFrame = CGRect(x: 21, y: 166, width: 356, height: 76) + static let contentHorizontalInset: CGFloat = 27 + static let columnSpacing: CGFloat = 18 + static let rowSpacing: CGFloat = 20 + static let cardCornerRadius: CGFloat = 18 + static let cardContentInset: CGFloat = 18 + static let minimumControlSize: CGFloat = 44 +} +``` + +Separate token constants (colors/shadows/type roles), component metrics, and screen placement metrics. Use existing `RecallPalette`; do not introduce near-duplicate literal colors inside view bodies. + +- [ ] **Step 3: Implement focused components** + +`KnowledgeLibrarySearchBar` uses a `TextField`, clear control, supplied microphone asset, and “帮我找” button. It supports keyboard `.search`, focus, listening border state, loading spinner, disabled empty submission and Dynamic Type growth. + +`KnowledgeLibraryCardView` renders the whole `coreKnowledge`. If `knowledgeSegments` exists, use an `AttributedString` with regular teal/cream context and semibold coral/cream semantic emphasis. It uses deterministic color/rotation based on card ID, a full-card button hit area and one combined accessibility element. + +`KnowledgeLibraryPager` creates an invisible non-accessible measurement layer at the real column width, receives actual rendered heights via a `PreferenceKey`, calls the pure paginator, and renders fixed-width pages in `TabView(indexDisplayMode: .never)`. The page indicator is one accessibility element. + +- [ ] **Step 4: Compose the screen and all states** + +Build the 402 × 874 reference composition with responsive safe-area scaling. Keep top chrome and search box stable; use the cream panel as the card/pager surface. Render: + +```swift +switch viewModel.state { +case .all, .results: + KnowledgeLibraryPager(cards: viewModel.visibleCards, currentPage: $viewModel.currentPage) +case .searching: + KnowledgeLibraryLoadingView() +case .noResults: + KnowledgeLibraryNoResultsView(onShowAll: viewModel.clearQuery) +case .failed(let message): + KnowledgeLibraryFailureView(message: message, onRetry: viewModel.retry) +} +``` + +When the store has no cards, show the empty-library state and keep the upload button active. The folder artwork remains decorative on this already-open library page. Back changes `store.selectedTab` to `.today`; upload calls the existing PhotosPicker sheet; card tap sets `store.presentedCard`. + +- [ ] **Step 5: Remove the old list without changing detail/upload contracts** + +Delete private `LibraryView` and `MemoryCardRow` from `ContentView.swift`. Keep `LibraryCardDetailView` and `AddScreenshotView`. Mount `KnowledgeLibraryView(cards:onBack:onAdd:onOpenCard:)` from the `.library` branch. + +- [ ] **Step 6: Build and inspect previews/Simulator** + +Build on an available iPhone Simulator. Expected: no clipping at 402 × 874 reference size, card text complete, search field usable with keyboard, and page swipes update dots. + +- [ ] **Step 7: Commit** + +```bash +git add Omo/Omo/ContentView.swift Omo/Omo/RecallDesign.swift Omo/Omo/KnowledgeLibrary Omo/Omo/Assets.xcassets Omo/Omo/Localizable.xcstrings docs/asset-provenance.md plans/codex-knowledge-library-search.md +git commit -m "feat: recreate the Omo knowledge library" +``` + +### Task 5: Add deterministic mock scenarios and stable documentation + +**Files:** +- Create: `Omo/Omo/KnowledgeLibrary/KnowledgeLibraryDebugFixtures.swift` +- Modify: `Omo/Omo/OmoStore.swift` +- Modify: `Omo/Omo/ContentView.swift` +- Modify: `docs/frontend/v2-frontend-architecture.md` +- Modify: `docs/frontend/v2-layout-system.md` +- Modify: `docs/ios-api-data-contract-zh.md` +- Modify: `docs/quality-baseline.md` +- Modify: `docs/knowledge-library-prd.md` +- Test: `Omo/OmoTests/KnowledgeLibrarySearchTests.swift` + +- [ ] **Step 1: Add synthetic long/short card fixtures** + +Create at least 12 entirely synthetic cards covering short, medium and multi-line complete knowledge, all rarity colors, legal/absent `hiddenSemantic`, verified/unverified sources, and stable IDs. They must not contain real user screenshots or production exports. + +- [ ] **Step 2: Add Debug-only scenario parsing** + +Under `#if DEBUG`, parse: + +```text +-OmoOpenLibrary +-OmoLibraryFixture many|empty +-OmoLibraryQuery +-OmoLibrarySearchNoResults +-OmoLibrarySearchFailure +-OmoLibraryVoiceTranscript +-OmoLibrarySpeechDenied +``` + +Injection occurs only after normal `store.load()` and only when explicit arguments are present. Release builds have no fixture branch. + +- [ ] **Step 3: Add deterministic mock search behavior** + +The mock normalizes whitespace/case and scores synthetic documents by explicit query aliases plus field token overlap, with stable ID tie-breaking. Name it `DebugMockKnowledgeLibrarySearcher`; expose a visible Debug accessibility/debug value such as “模拟搜索数据” where appropriate. A configured failure/no-result mode returns exactly those states. Do not call it vector or semantic search in production-facing copy. + +- [ ] **Step 4: Update stable contracts** + +Document the implemented SwiftUI feature, protocol boundary, mock limitation, measured pagination, Speech privacy behavior and exact validation commands. In the API contract, label the future vector Endpoint as unimplemented and do not assign a live path unless backend code exists. + +- [ ] **Step 5: Run documentation and unit gates** + +```bash +npm --prefix backend run docs:check +git diff --check +xcodebuild test -project Omo/Omo.xcodeproj -scheme Omo -destination 'platform=iOS Simulator,id=' +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add Omo/Omo Omo/OmoTests docs plans/codex-knowledge-library-search.md +git commit -m "test: add knowledge library acceptance fixtures" +``` + +### Task 6: Simulator interaction audit and evidence + +**Files:** +- Create/Modify: `docs/validation/knowledge-library-search-2026-08-03.md` +- Create: screenshot evidence under an ignored or documented validation-artifact path; do not commit personal/user data. +- Modify: `plans/codex-knowledge-library-search.md` + +- [ ] **Step 1: Discover and boot actual destinations** + +Run `xcodebuild ... -showdestinations`, select a common device plus a smaller or larger available iPhone, build the Debug app, boot/install/launch with explicit fixture arguments. + +- [ ] **Step 2: Capture every required state** + +Capture screenshots for all cards page 1, a later page, text query result, voice listening, simulated voice result, no results, failure, empty library, complete detail and upload sheet. Record bundle arguments, device, OS and screenshot path in the validation document. + +- [ ] **Step 3: Audit interactions, not only pixels** + +Actually type and submit through the Simulator keyboard, clear query, retry failure, tap mic, stop/restart, swipe pages, open a card, return, open upload, cancel upload and return home. Verify new queries reset page 1 and stale results never replace the current query. + +- [ ] **Step 4: Audit system variants** + +Check the common and alternate size, largest practical Dynamic Type, Reduce Motion, VoiceOver labels/reading order, keyboard/focus, light/dark system appearance, safe areas, hit targets, scrolling and horizontal overflow. Fix failures and recapture evidence. + +- [ ] **Step 5: Run final gates** + +```bash +npm --prefix backend run docs:check +git diff --check +xcodebuild test -project Omo/Omo.xcodeproj -scheme Omo -destination 'platform=iOS Simulator,id=' +xcodebuild build -project Omo/Omo.xcodeproj -scheme Omo -configuration Debug -destination 'platform=iOS Simulator,id=' +``` + +Expected: all exit 0. State explicitly that Simulator mock speech does not prove real-device recognition and mock search does not prove production vector retrieval. + +- [ ] **Step 6: Commit validation evidence** + +```bash +git add docs/validation/knowledge-library-search-2026-08-03.md plans/codex-knowledge-library-search.md +git commit -m "test: record knowledge library simulator acceptance" +``` + +### Task 7: Completion audit, plan retirement, push and review handoff + +**Files:** +- Modify then delete: `plans/codex-knowledge-library-search.md` +- Modify: `PLANS.md` + +- [ ] **Step 1: Audit every explicit requirement** + +Map Figma visual structure, complete browsing, text input, speech input, replaceable mock search, all states, measured pagination, PRD, tests, Simulator interaction and screenshots to direct code/test/runtime evidence. Continue fixing if any evidence is missing or only indirect. + +- [ ] **Step 2: Complete the repository plan** + +Set status `completed`, check every finished task, record commands/results, devices, screenshot paths, unverified production vector/real-device speech boundaries and final commit SHAs. Commit only the completion evidence: + +```bash +git add plans/codex-knowledge-library-search.md PLANS.md +git commit -m "plan: complete codex-knowledge-library-search" +``` + +- [ ] **Step 3: Retire the plan before PR** + +Delete the plan, restore `PLANS.md` to the empty table, run docs check and diff check, then commit: + +```bash +git add plans/codex-knowledge-library-search.md PLANS.md +git commit -m "plan: retire codex-knowledge-library-search" +``` + +- [ ] **Step 4: Push only the topic branch** + +Push `codex/knowledge-library-search` to `omo`. Do not push or merge `main`. Because this is stacked on the open active-recall branch, make the dependency explicit in the PR/handoff and choose the safest review base supported by the team’s current PR state without rewriting either shared branch. + +## Self-review + +- Spec coverage: every requirement in the design spec maps to Tasks 1–7, including PRD, exact assets, measured heights, text/voice states, mock honesty, Debug fixtures, accessibility, Simulator screenshots and branch lifecycle. +- Placeholder scan: no `TBD`, generic “handle errors”, or unspecified test step remains. Future production vector work is explicitly a non-goal and contract boundary, not an implementation placeholder. +- Type consistency: request, response, search protocol, speech event, result state, view-model and paginator names are consistent across tasks. The implementation may add access modifiers but must not silently rename the frozen concepts without updating the spec and tests. +- Execution choice: the user requested autonomous execution in this goal. The primary agent will execute inline; no subagents are dispatched because this session’s collaboration policy does not authorize them. diff --git a/docs/superpowers/specs/2026-08-02-active-recall-selective-reintegration-design.md b/docs/superpowers/specs/2026-08-02-active-recall-selective-reintegration-design.md new file mode 100644 index 0000000..524a851 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-active-recall-selective-reintegration-design.md @@ -0,0 +1,231 @@ +# 主动回忆交互选择性恢复规格 + +- 日期:2026-08-02 +- 状态:待产品书面复核 +- 团队主线基线:`omo/main@cb23265` +- 旧版交互参考:`codex/fix-active-recall-interaction-impl@009e943` +- 实施分支:`codex/continue-from-latest-main` +- 目标:在团队最新代码之上恢复此前已验收的 Omo 首页与主动回忆体验,同时保留团队后来完成的运行时安全、Profile、存储保护、测试与协作体系 + +## 1. 问题与约束 + +团队最新主线已经增加 production fail-closed、显式 Demo 模式、readiness、错误脱敏、JSON 写入回滚、Profile 页面和新的协作文档;这些改动必须保留。当前主线的主动回忆体验却退回为独立页面、整块答案刮开、较低揭示门槛和三按钮自评,与此前确认的产品主线不一致。 + +本次不是回滚团队版本,也不是把旧分支整体覆盖到主线,而是在最新主线的架构和数据保护之上,选择性恢复已经验收的前端体验与其必要的数据合同。 + +## 2. 实施策略 + +采用“当前主线为骨架、旧版能力逐项移植”的方案: + +1. 从 `cb23265` 创建并只在 `codex/continue-from-latest-main` 开发,不直接修改或合并 `main`。 +2. 保留当前 `OmoStore`、`APIClient`、上传、删除、assessment、Profile 与后端运行配置。 +3. 恢复旧版首页、牌堆、句内刮开、四位置滑动自评及对应测试。 +4. 将 `hiddenSemantic` 合同接入当前 `cardService`、`runtimeConfig`、持久化和错误语义,而不是替换整个后端。 +5. 完成双端自动测试与 Simulator 全状态人工验收后,再创建面向 `main` 的新 PR。 + +不采用整体 cherry-pick、revert the revert 或以旧分支为新基线;这些方式会覆盖团队新增能力并扩大回归范围。 + +## 3. 必须保留的团队改动 + +- `backend/src/runtimeConfig.js` 及 production fail-closed 行为。 +- `/api/health` 与 `/api/readiness` 的职责区分。 +- 仅在非生产环境显式设置 `OMO_DEMO_MODE=1` 时使用 Fixture;生产环境禁止 Demo 模式。 +- 模型、来源和存储错误码及脱敏响应,不泄露密钥、截图、上游正文或完整载荷。 +- 当前 JSON Store 写入失败回滚、设备隔离、assessment 幂等与调度状态机。 +- 当前后端测试基线,以及 `.github` Issue、PR、工作流和稳定文档体系。 +- 团队新增的独立 `ProfileView` 及其大数据、Dynamic Type 和无障碍处理。 +- 当前上传、知识库、删除和完整卡片展示能力;知识库中的卡片无需刮开。 + +当前尚未合并的 PostgreSQL 与 Library Detail PR 不纳入本分支,也不复制其代码。 + +## 4. 恢复的首页和导航 + +应用打开后进入此前确认的暖色 Figma 首页: + +- IP 是默认十连抽入口;有可复习卡时点击 IP 开始本轮。 +- 收藏夹是知识库入口;上传是次级入口,点击后调起系统照片选择器。 +- 收藏夹和上传按钮在空闲、抽卡动画、刮卡、自评和换卡期间持续存在,用户不会被锁在做题页。 +- 侧边菜单提供 Profile 与 Settings;复用团队当前 `ProfileView`,不重新设计 Profile。 +- 不保留当前底部 Today / Library / Profile Tab 作为主要导航,以免与已确认的首页入口形成两套竞争导航。 +- 首次库为空时仅用箭头引导用户上传自己的截图,不预置非用户内容。 +- 有卡时首页引导点击 IP;具体 IP 形象和动画创意仍沿用现有素材,不在本次重设计。 + +召回发生在首页同一场景内,不跳转为全屏做题页面,也不提供中途关闭按钮。 + +## 5. 卡片数据合同 + +`MemoryCard` 增加可兼容解码的 `hiddenSemantic`。新生成且可进入主动回忆牌组的卡片必须满足: + +1. `coreKnowledge` 是一条完整、独立可理解、受截图或来源证据支持的知识表达。 +2. `hiddenSemantic` 非空,并且是 `coreKnowledge` 中字符完全一致的连续子串。 +3. `hiddenSemantic` 是句子的承重语义,移除后会形成真实的主动回忆缺口。 +4. `answer` 暂时保留用于旧客户端兼容;新卡将其镜像为 `hiddenSemantic`,两者不得冲突。 + +旧卡缺少合法 `hiddenSemantic` 时仍在知识库完整显示,不删除、不改写,但不进入主动回忆牌组。iOS 不得从 `answer` 或 `coreKnowledge` 自行猜测遮挡词。 + +## 6. 生成、验证与失败语义 + +当前模型 Adapter 增加 `hiddenSemantic` 输出要求,服务端在保存前验证连续子串合同: + +- 首次合法:正常保存。 +- 首次不合法:基于同一截图输入与具体校验错误执行一次模型结构修复。 +- 第二次仍不合法:返回稳定的 502 无效模型响应,不保存卡片,不由客户端补猜。 +- 显式 Demo Fixture 必须生成合同合法的 `hiddenSemantic`,并继续标记 `generationMode: fixture`。 + +自动溯源是截图上传后默认触发的 workflow,不向用户询问是否溯源。溯源失败不阻止生成:只根据截图生成最低稀有度 R 卡;溯源失败与语义合同失败是两类独立错误。production fail-closed、readiness 和脱敏规则在修复请求中同样生效。 + +## 7. 默认十连抽与牌堆 + +- 点击 IP 后,从当前到期且合同合法的卡中按现有顺序最多取 10 张;不足 10 张时抽出剩余全部卡片。 +- MVP 只有默认十连抽,不提供单抽切换、分类抽取、关键词选择或保底文字提示。 +- 本轮顺序开始时冻结,不在中途重新抽卡,也不保存“退出后恢复半轮”的状态。 +- 牌堆使用四层视觉深度;当前卡之下的下一张卡可通过微弱光芒暗示稀有度。 +- 稀有度只是视觉装饰,不是点击、确认或单独揭示的交互步骤,也不影响自评和复习调度。 +- 最后一张自评成功后结束本轮,首页恢复空闲状态。 + +## 8. 句内刮刮乐 + +卡片正文连续呈现: + +```text +prefix + [被遮住的 hiddenSemantic] + suffix +``` + +- 只遮住 `hiddenSemantic` 的实际文字区域,不遮整段 `answer`、整张卡或原截图。 +- 刮开一点只显示实际刮到区域的文字。 +- 覆盖率低于 80% 时不显示自评滑条。 +- 达到 80% 时归一为完全揭示,触发一次轻震动并显示自评滑条。 +- 不提供“直接揭示”按钮;用户通过刮动完成当前交互。 +- 揭示后 `hiddenSemantic` 使用更高字重与强调色,前后文使用较轻字重和正文色,形成明确的视觉权重。 +- 稀有度原因和来源属于卡片上的次级信息,不是必须经过的步骤。 +- 卡片可展开详情 Sheet,展示完整知识、简短解释、来源标题、账号、平台、链接与真实来源状态;打开或关闭详情不改变刮开进度。 + +揭示前,答案不得出现在可见文字、VoiceOver label/value/hint、无障碍树、调试叠层或可被用户直接读取的状态文案中。 + +## 9. 四位置滑动自评 + +滑条从左到右有四个位置: + +| 位置 | 语义 | 松手结果 | +| --- | --- | --- | +| `0.00` | 取消 | 不提交,复位,当前卡不切换 | +| `0.42` | 忘了 | 提交 `forgot` | +| `0.70` | 没记清 | 提交 `fuzzy` | +| `0.97` | 记住了 | 提交 `remembered` | + +- 滑块初始在最左端;进入三个 assessment 节点时各触发一次轻震动。 +- 轨道底层始终是一条从左到右完整、固定的多色渐变;拖动只改变已填满区域的遮罩宽度,因此滑块所在处显示该绝对位置对应的渐变色。 +- 不能把“起点到当前位置”重新计算成一条局部双色渐变。 +- 滑块描边或投影可跟随当前位置,滑块内部箭头始终保持固定青绿色,不参与渐变。 +- 在 assessment 节点松手即确认;提交成功后自动换下一张。 +- 提交期间阻止重复提交。失败时保留已揭示卡和当前选择,原地提供重试,不换卡。 + +自评只影响团队现有 mastery 与下次复习时间,不改变稀有度。 + +## 10. 状态与数据流 + +首页本轮状态: + +```text +idle +→ summon +→ covered +→ scratching +→ revealed +→ submitting +→ next card / submission failed +→ complete +→ idle +``` + +端到端数据流: + +```text +截图上传 +→ 后台自动溯源 +→ 生成 coreKnowledge + hiddenSemantic +→ 验证连续子串并按需修复一次 +→ 保存 MemoryCard +→ iOS 只将合法且到期卡加入最多十张的牌组 +→ 句内刮开达到 80% +→ 四位置滑动自评 +→ POST assessment +→ 成功后首页原位换下一张 +``` + +上传失败保留首页及已有数据并给出可重试反馈;加载失败不得用 Fixture 伪装成功。真实外部服务在没有凭据时只能报告未验证。 + +## 11. 代码边界 + +iOS: + +- `ContentView` 负责启动、侧边菜单、知识库、Profile、Settings 与上传 Sheet 的路由。 +- `RecallHomeView` 负责首页视觉、入口持续可见与本轮编排。 +- `RecallRoundView`、`RecallKnowledgeCardView`、`RecallRatingSlider` 和 `RecallInteractionState` 分别承载牌堆、句内刮开、自评与纯状态转换。 +- `MotionKit` 继续只承载动效原语,不成为业务状态事实来源。 +- `OmoStore` 与 `APIClient` 继续是加载、上传、删除、assessment 的唯一动作边界。 + +后端: + +- 在当前 `cardService` 中扩展生成、验证和一次修复,不恢复平行服务。 +- 所有配置继续经 `runtimeConfig` 解析;禁止新代码直接绕过它读取互相矛盾的环境变量。 +- 当前 Store 与路由合同只做向后兼容扩展,不删除已有字段或端点。 + +## 12. 无障碍、动效与隐私 + +- 所有主要点击目标不小于 44pt,并适配安全区和 Dynamic Type。 +- Reduce Motion 下取消非必要位移和粒子动画,但保留抽卡、揭示、自评和换卡的完整因果与静态反馈。 +- VoiceOver 在揭示前不读取答案;揭示后才提供完整知识语义。 +- Fixture、测试、文档和公开仓库不得包含真实用户截图、Base64、密钥或完整模型载荷。 +- Simulator 截图使用合成或授权素材,并明确它只能证明客户端状态,不代表真实生产链路。 + +## 13. 验收与测试 + +后端自动测试: + +- 当前主线全部测试继续通过,包括 production fail-closed、readiness、Demo 模式、错误脱敏、Store 回滚与 assessment。 +- 新卡返回合法 `hiddenSemantic`;非法结果只修复一次,二次失败返回 502 且不持久化。 +- screenshot-only 卡仍生成且固定为 R。 +- Fixture 仅在显式非生产 Demo 模式生效并满足新合同。 +- 旧卡读取不丢失,兼容字段不被错误改写。 + +iOS 自动测试: + +- 新旧 `MemoryCard` 均可解码,只有合同合法且到期的卡进入牌组。 +- 句首、句中、句尾及重复语义均有确定分段行为,重复时遮挡第一次精确匹配。 +- 79% 不展示自评,80% 完整揭示。 +- 四个位置分别执行取消、forgot、fuzzy、remembered。 +- 提交失败不换卡,重试成功只换一次。 +- 默认牌组最多 10 张,收藏夹与上传入口在整轮持续存在。 + +Simulator 人工验收: + +- 在主验证设备和一个较小屏幕设备检查首次空库、有卡首页、抽卡动画、四层牌堆、covered、局部刮开、80% 揭示、自评取消、三个结果、提交失败、换卡、最后一张和详情 Sheet。 +- 审查触控范围、安全区、键盘或系统照片选择器返回、Dynamic Type、Reduce Motion、VoiceOver 与答案泄露。 +- 为每个关键页面和状态保留截图,交由产品逐页审查视觉与交互。 + +## 14. 非目标 + +- 不直接修改、推送或合并 `main`。 +- 不合入当前开放的 PostgreSQL 或 Library Detail PR。 +- 不重新设计 IP、Profile、Settings 或知识库次级视觉。 +- 不增加单抽、分类抽、关键词选择、保底规则、半轮恢复、用户纠错或主动推荐。 +- 不在本次恢复通知卡;通知提问仍是产品核心方向,但另行实现和验收。 +- 不宣称真实 Qwen、TikHub、APNs、持久数据库或生产部署已验证,除非具备真实凭据和独立证据。 + +## 15. 自检结论 + +- 团队新增能力与要恢复的产品体验已分开列明。 +- 导航、牌组、刮开、自评、详情、失败和旧卡兼容没有平行事实来源。 +- `hiddenSemantic`、80% 门槛、四位置坐标、固定全轨渐变、固定箭头颜色和持续入口均有可测试定义。 +- 实施不依赖覆盖团队代码,可拆成小提交并在新 PR 中逐项审查。 + +## 相关文档 + +- [[docs/superpowers/plans/2026-08-02-active-recall-selective-reintegration]] +- [[docs/product-principles]] +- [[docs/ios-api-data-contract-zh]] +- [[docs/frontend/v2-frontend-architecture]] +- [[docs/frontend/v2-layout-system]] +- [[docs/quality-baseline]] +- [[AGENTS]] diff --git a/docs/superpowers/specs/2026-08-03-knowledge-library-search-design.md b/docs/superpowers/specs/2026-08-03-knowledge-library-search-design.md new file mode 100644 index 0000000..6782650 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-knowledge-library-search-design.md @@ -0,0 +1,255 @@ +# Omo 知识库浏览与搜索设计规格 + +日期:2026-08-03 +状态:已确认,进入实施 +设计来源:Figma `Pick The Shell` node `884:324`、用户提供 SVG、当前 Omo 产品合同 +实现边界:完整 SwiftUI 交互 + 可替换 mock 搜索数据层;真实后端向量服务仅冻结合同 + +## 1. 问题与目标 + +用户保存的知识卡不断增加后,需要在“不进入复习、不重新刮开”的前提下,快速浏览和找回已经整理好的完整知识。当前系统 `LibraryView` 只是系统列表,缺少 Omo 的视觉语言,也没有文字/语音检索或分页。 + +本功能的目标不是帮助用户再次测试记忆,而是提供一个低摩擦的“知识取回”入口: + +1. 进入知识库即可看到全部完整知识卡片。 +2. 用户可用自然语言描述想找的知识,也可口述查询。 +3. 搜索结果只重排已有卡片,不生成新知识、不改写原卡、不泄露其他用户数据。 +4. 页面保持轻盈、具备收藏夹式的视觉感,同时在卡片数量和文字长度变化时仍然稳定。 + +## 2. 已确认产品边界 + +- 知识库卡片直接显示 `coreKnowledge` 完整内容,不使用刮层。 +- 首页收藏夹是知识库入口;知识库顶部返回按钮回到首页。 +- 知识库加号继续打开已有的截图上传流程。 +- 点击卡片打开已有的完整知识详情。 +- 本轮不增加分类、筛选、标签、知识图谱、编辑和搜索历史。 +- 本轮不建设生产 embedding、向量数据库或后端索引任务。 +- Mock 只用于 UI、状态机和 Simulator 验收,必须显式标记且可被生产实现替换;产品文档不得把它写成真实向量检索已经上线。 + +## 3. 方案比较与选择 + +### 方案 A:可替换搜索协议 + 完整 mock 交互(采用) + +iOS 依赖 `KnowledgeLibrarySearching`,页面只理解请求、结果和失败状态。本轮注入确定性的本地 mock 搜索器,后续真实向量 Endpoint 可在不改 UI 状态机的前提下替换。 + +优势:可以忠实完成设计和全状态验收;不会为了赶页面而制造一个难以替换的伪后端;测试确定。限制:本轮不能用 Simulator 截图证明真实语义召回质量。 + +### 方案 B:同步建设真实后端向量检索(不采用) + +需要确定 embedding 模型、索引生命周期、向量存储、权限过滤、删除一致性、费用和降级语义,超出本轮页面还原的范围。 + +### 方案 C:设备端简化 embedding(不采用) + +避免后端依赖,但中文和领域术语覆盖、设备差异、模型可用性及未来迁移成本都不稳定,也不符合既定的知识库服务边界。 + +## 4. 信息架构 + +```mermaid +flowchart TD + Home["首页:收藏夹入口"] --> Library["知识库:全部完整卡片"] + Library --> TextSearch["文字查询"] + Library --> VoiceSearch["语音查询"] + TextSearch --> Results["相关卡片分页结果"] + VoiceSearch --> Results + Results --> Detail["完整知识详情"] + Library --> Upload["系统照片选择与截图上传"] + Library --> Home +``` + +知识库是首页的次级页面,但它自身是“浏览全部”和“主动取回”的统一入口。搜索框固定在卡片区域上方,不因翻页移动。 + +## 5. Figma 视觉结构 + +以 402 × 874 pt 为参考画布: + +- 全屏橙色背景使用现有 `RecallPalette.background`。 +- 左上为用户提供的奶油色返回按钮,最小可点击区域不小于 44 × 44 pt。 +- 右上复用 Omo 角色素材;角色是品牌氛围,不承担搜索状态的必要信息。 +- 奶油色主体面板从搜索框后方延伸到底部,复用现有面板色与圆角语言。 +- 搜索框约位于 `x: 21, y: 166, width: 356, height: 76`;奶油底、青绿色描边、20 pt 圆角和轻投影。 +- 麦克风使用用户提供 SVG;“帮我找”位于搜索框右侧,搜索中替换为真实旋转的 `ProgressView`,不显示虚假百分比。 +- 卡片区域为两列错落布局,使用奶油、青绿、珊瑚三种受控表面色和轻微旋转;旋转只服务于收藏卡片氛围,不改变阅读顺序。 +- 底部页点反映当前页;底部收藏夹为页面氛围与知识库语义,上传按钮继续是可操作入口。 +- 使用 SF 系统字体和 Dynamic Type,不导入 Figma 的 Inter。 + +## 6. 卡片排版与分页 + +### 6.1 卡片内容 + +卡片至少显示完整 `coreKnowledge`。如果 `hiddenSemantic` 是合法的连续子串,可以用加粗和对比色强调该片段,但绝不遮挡。稀有度只参与颜色/边框等视觉装饰,不形成额外操作步骤,也不代表搜索相关度。 + +### 6.2 高度适配 + +卡片高度必须由实际字体、宽度、Dynamic Type 和文本排版测量得到: + +`cardHeight = measuredTextHeight + verticalInsets + optionalMetadataHeight` + +不得使用“少于 N 个字用某高度”之类的规则。测量算法与实际卡片使用同一字体、字距、行距和宽度约束;长文本允许完整换行,不截断。 + +### 6.3 分页算法 + +1. 读取卡片区域的实际宽高与 Dynamic Type 环境。 +2. 计算两列固定可用宽度。 +3. 按结果顺序把每张卡贪心放入当前页高度较短的一列。 +4. 如果放入后会超过页面内容高度,且当前页已至少包含一张卡,则新建下一页。 +5. 旋转的视觉包围盒预留安全边距,避免被裁切或碰撞。 +6. 查询结果、容器尺寸或 Dynamic Type 变化时重新分页并回到第一页。 + +页面使用横向分页手势;自定义页点由真实页数生成。超过可舒适显示的页点数量时,仅显示当前页附近窗口并提供 VoiceOver “第 X 页,共 Y 页”,不压缩成不可点击的小点阵。 + +## 7. 搜索交互 + +### 7.1 默认与文字输入 + +- 空查询默认展示全部卡片。 +- 点击输入框出现键盘,return key 为 Search。 +- 键盘 Search 或“帮我找”提交同一动作。 +- 只输入空白等同清空,立即恢复全部卡片。 +- 输入变化不会自动发出向量请求,避免不必要的网络成本和结果抖动。 +- 用户可通过清除按钮清空查询;清空时取消正在执行的请求并回到全部卡片第一页。 + +### 7.2 请求并发 + +每次提交生成独立请求 ID。新请求取消旧 Task;即使旧服务未及时响应,结果也只有在 ID 仍为当前请求时才可写入状态。页面离开时取消请求和语音监听。 + +### 7.3 搜索状态 + +```mermaid +stateDiagram-v2 + [*] --> BrowsingAll + BrowsingAll --> Editing: 输入文字 + Editing --> Searching: 提交非空查询 + Searching --> Results: 返回至少一张卡 + Searching --> NoResults: 返回空结果 + Searching --> Failed: 请求失败 + Results --> Searching: 提交新查询 + NoResults --> Searching: 修改后重试 + Failed --> Searching: 重试 + Editing --> BrowsingAll: 清空输入 + Results --> BrowsingAll: 清空输入 +``` + +- `Searching`:保留当前查询;结果区域使用克制的加载反馈,防止误点旧结果。 +- `Results`:按相关性顺序显示,不向用户暴露内部相似度分值。 +- `NoResults`:说明没有找到匹配卡片,提供“查看全部”;它与服务失败视觉和语义不同。 +- `Failed`:保留查询,提供“重试”;不自动清空或静默展示全部。 +- `EmptyLibrary`:说明还没有知识卡,搜索控件保持可理解但不可提交,加号仍可上传。 + +## 8. 语音输入 + +### 8.1 用户流程 + +1. 用户点击麦克风;首次使用请求 Speech Recognition 和 Microphone 权限。 +2. 授权后进入 listening,搜索框描边和麦克风有状态变化,同时显示实时转写。 +3. 用户再次点击停止,或系统确认最终转写后停止。 +4. 非空最终转写自动提交搜索;空转写回到编辑态并给出轻量反馈。 +5. 页面离开、开始文字提交或出现系统中断时停止音频引擎并释放 tap。 + +### 8.2 权限和失败 + +- 未决定:触发系统权限请求。 +- 拒绝/受限:不重复弹系统请求,显示简短说明和“前往设置”。 +- 识别器不可用:显示“暂时无法使用语音输入”,文字搜索继续可用。 +- 转写失败:保留已有转写文字,允许用户编辑或重试。 +- App 不保存原始音频;只把转写后的查询字符串交给搜索器。 + +Simulator 通过 Debug-only `KnowledgeLibrarySpeechTranscribing` mock 注入最终转写,验证 UI 状态与搜索衔接。真机权限、麦克风质量和网络语音服务不在本轮可证明范围内。 + +## 9. 数据合同 + +```swift +struct KnowledgeLibrarySearchDocument: Equatable, Sendable { + let id: String + let coreKnowledge: String + let recallCue: String + let explanation: String + let sourceTitle: String +} + +struct KnowledgeLibrarySearchRequest: Equatable, Sendable { + let query: String + let candidates: [KnowledgeLibrarySearchDocument] +} + +struct KnowledgeLibrarySearchResponse: Equatable, Sendable { + let orderedCardIDs: [String] +} + +protocol KnowledgeLibrarySearching: Sendable { + func search(_ request: KnowledgeLibrarySearchRequest) async throws + -> KnowledgeLibrarySearchResponse +} +``` + +UI 只向搜索器提供当前用户已加载卡片构成的候选边界;真实后端 Adapter 只需发送候选 ID,本地 Debug mock 则可读取合成候选文字。响应只接受这些候选中的 ID,映射时过滤未知、重复或已经删除的 ID。后续真实服务至少需要: + +- 在鉴权用户范围内检索; +- 索引新增、更新和删除与卡片持久化一致; +- 返回稳定有序的 card IDs; +- 支持请求取消/超时和明确错误; +- 不回传 embedding 或内部相似度; +- 不把查询或音频用于未披露的训练与长期留存。 + +本轮 mock 可以使用合成关键词别名和确定性延迟来生成成功、无结果与失败,但命名、文档和 Debug 标签必须清楚表明它不等于生产向量检索。 + +## 10. 组件与文件边界 + +- `KnowledgeLibraryView`:页面装配、导航、详情和上传动作。 +- `KnowledgeLibraryViewModel`:查询、状态机、请求取消、卡片映射和语音协调。 +- `KnowledgeLibrarySearchBar`:文字、提交、清除、麦克风和加载状态。 +- `KnowledgeLibraryCardView`:完整知识、承重语义视觉强调、配色和无障碍内容。 +- `KnowledgeLibraryPager` / `KnowledgeLibraryPagination`:测量、两列分页、当前页和页点。 +- `KnowledgeLibrarySearching`:可替换搜索边界和 Debug mock。 +- `KnowledgeLibrarySpeechTranscribing`:可替换语音转写边界;生产实现封装 Speech/AVFoundation。 + +`ContentView` 只负责把 store 卡片、详情、返回和上传闭包注入页面,不继续承载知识库内部实现。 + +## 11. 可访问性与系统适配 + +- 返回、麦克风、清除、搜索、上传、重试和查看全部均至少 44 pt 命中区域。 +- 卡片作为一个 VoiceOver 元素,读出“稀有度、完整知识、来源;按钮”;视觉旋转不改变阅读顺序。 +- 页点整体提供“第 X 页,共 Y 页”,装饰点不逐个制造焦点。 +- listening、搜索完成、无结果和失败通过适度 live region/announcement 反馈。 +- Dynamic Type 下搜索栏可以增高;“帮我找”不遮挡输入;卡片重新测量分页。 +- Reduce Motion 下禁用卡片翻页附加弹性、麦克风脉冲和装饰性过渡,但保留状态颜色与进度指示。 +- 页面沿用固定品牌浅色视觉;如果系统深色模式下仍保持该视觉,需要确保文字对比度,不以自动反色破坏 Figma 配色。 +- 键盘出现时搜索框保持可见,卡片区可缩放/滚动,不产生横向溢出。 + +## 12. Mock、测试和验收场景 + +仅 Debug 启动参数可注入以下合成场景: + +- `-OmoOpenLibrary -OmoLibraryFixture many`:多页长短混合卡片。 +- `-OmoLibraryQuery "认知"`:预填并执行成功查询。 +- `-OmoLibrarySearchNoResults`:无结果。 +- `-OmoLibrarySearchFailure`:失败与重试。 +- `-OmoLibraryVoiceTranscript "如何避免认知卸载"`:模拟语音最终转写并搜索。 +- `-OmoLibraryFixture empty`:空知识库。 + +这些参数不得进入 Release 行为。单元测试必须覆盖: + +- 空白查询恢复全部; +- 新请求覆盖旧请求,旧响应不得污染; +- 未知/重复 ID 被过滤; +- 成功、空结果、失败与重试状态; +- 语音最终转写触发一次搜索; +- 不同卡片高度和容器高度的分页、顺序、页面重置; +- 合法 `hiddenSemantic` 的视觉分段与旧卡完整展示; +- Dynamic Type 变化后的重新分页。 + +Simulator 截图至少包含:全部卡片、多页第二页、文字结果、语音 listening/结果、无结果、失败、空库、详情和上传入口。每张截图记录设备、尺寸、启动参数和人工结论。 + +## 13. 成功标准与未验证项 + +成功必须同时满足:视觉结构与 Figma 一致、完整卡片无截断、输入和状态闭环可操作、分页根据实际排版、测试通过、Simulator 全状态可复现、稳定文档明确 mock 边界。 + +本轮完成后仍明确未验证:生产向量搜索质量与延迟、真实后端权限隔离、真机麦克风权限与转写质量、真实用户大规模卡片性能。这些不影响页面与可替换边界交付,但不得在 PR 中写成已完成。 + +## 相关文档 + +- [[docs/index]] +- [[docs/product-principles]] +- [[docs/ios-api-data-contract-zh]] +- [[docs/frontend/v2-frontend-architecture]] +- [[docs/frontend/v2-layout-system]] +- [[docs/asset-provenance]] diff --git a/docs/support-zh.md b/docs/support-zh.md index 15c371f..858d00c 100644 --- a/docs/support-zh.md +++ b/docs/support-zh.md @@ -1,6 +1,6 @@ # Omo 支持页面草案 -更新日期:2026 年 7 月 2 日 +更新日期:2026 年 8 月 8 日 > 本文档是 `docs/support.html` 的可编辑 Markdown 草案。正式提交 App Store 前,需要把“支持邮箱:mingyuhan0814@gmail.com”替换为真实对外邮箱,并将 HTML 页面部署到公开 URL。 @@ -34,15 +34,15 @@ ### 如何删除我的数据? -可以在 App 的“我的”页面使用“删除我的数据”。该操作会删除当前匿名设备身份下的记忆卡、复习记录、通知、生成任务和推送 token 等可用数据。 +如果需要删除当前匿名设备身份下的云端数据,请通过上方支持邮箱提出请求。当前 TestFlight 版本尚未提供 App 内一键删除全部数据的入口。 ### 如何关闭通知? -可以在 iOS 系统设置中关闭 Omo 的通知权限。关闭后,App 内仍然可以查看记忆卡和通知列表。 +可以在 iOS 系统设置中关闭 Omo 的通知权限。关闭后,App 内仍然可以查看和复习记忆卡。当前 TestFlight 版本的复习通知由设备本地安排,通知只显示回忆问题,不显示答案、解释或原截图。 ### 如何反馈记忆卡问题? -如果记忆点、题目、选项、解释或来源不准确,可以在 App 内使用反馈入口;也可以联系支持并附上截图,帮助我们定位具体卡片。 +如果记忆点、解释或来源不准确,请联系支持并附上截图和大致生成时间,帮助我们定位具体卡片。 ## 隐私政策 diff --git a/docs/support.html b/docs/support.html index ff87cfb..c42ea83 100644 --- a/docs/support.html +++ b/docs/support.html @@ -95,7 +95,7 @@

Omo 支持

-

更新日期:2026 年 7 月 2 日

+

更新日期:2026 年 8 月 8 日

Omo 把用户主动保存的截图或平台内容转化为有证据绑定的记忆卡,并通过主动回忆和复习调度帮助用户再次想起内容。如果你在生成、复习、通知、数据删除或隐私方面遇到问题,可以通过本页面找到处理方式。

@@ -121,13 +121,13 @@

常见问题

当前版本主要使用匿名设备身份保存数据。删除 App、换机或安装到不同 App 身份上下文后,历史数据可能无法自动关联回来。我们正在评估账号和跨设备恢复能力。

如何删除我的数据?

-

可以在 App 的“我的”页面使用“删除我的数据”。该操作会删除当前匿名设备身份下的记忆卡、复习记录、通知、生成任务和推送 token 等可用数据。

+

如果需要删除当前匿名设备身份下的云端数据,请通过上方支持邮箱提出请求。当前 TestFlight 版本尚未提供 App 内一键删除全部数据的入口。

如何关闭通知?

-

可以在 iOS 系统设置中关闭 Omo 的通知权限。关闭后,App 内仍然可以查看记忆卡和通知列表。

+

可以在 iOS 系统设置中关闭 Omo 的通知权限。关闭后,App 内仍然可以查看和复习记忆卡。当前 TestFlight 版本的复习通知由设备本地安排,通知只显示回忆问题,不显示答案、解释或原截图。

如何反馈记忆卡问题?

-

如果记忆点、题目、选项、解释或来源不准确,可以在 App 内使用反馈入口;也可以联系支持并附上截图,帮助我们定位具体卡片。

+

如果记忆点、解释或来源不准确,请联系支持并附上截图和大致生成时间,帮助我们定位具体卡片。

隐私政策

关于 Omo 收集哪些信息、如何使用 AI 模型处理内容、如何保存和删除数据,请查看 Omo 隐私政策。

diff --git a/plans/codex-omo-independent-app.md b/plans/codex-omo-independent-app.md new file mode 100644 index 0000000..532d7ef --- /dev/null +++ b/plans/codex-omo-independent-app.md @@ -0,0 +1,282 @@ +# Omo 核心交互故障审查与修复计划 + +- 状态:`in_progress` +- 优先级:P0 +- 创建:2026-08-08 +- 更新:2026-08-08 +- 负责人:Codex `/root` +- 整合者:Codex `/root` +- 分支:`codex/omo-independent-app` +- Worktree:`/Users/hanmingyu/Documents/Recallo2.0/Omo-next` +- 依赖:Omo staging、Railway Postgres、Qwen/TikHub 测试配置 +- 推进模式:`auto` +- 可写路径:`Omo/`、`backend/`、`docs/`、`plans/codex-omo-independent-app.md`、`PLANS.md` +- 禁止路径:production 环境、`main` 分支、其他产品的 App Store Connect 记录 +- 高冲突文件唯一写者:Codex `/root`(`Omo/Omo.xcodeproj/project.pbxproj`、`backend/migrations/`、`PLANS.md`) +- 范围:空库首页、截图上传与任务恢复、抽卡可达性、知识库处理状态、真实验收 +- 安全边界:只修改当前功能分支与 Omo staging,不部署 production,不修改 `main` + +## 当前进度(2026-08-09) + +- 已完成异步截图任务合同、Postgres migration `003`、worker lease / heartbeat / attempt fencing、iOS 本地恢复与同 ID 重试。 +- 已完成空库、处理中、失败、复习和通知叠卡状态的持久入口修复;App 内两个上传入口共用 Upload Coordinator。 +- 后端全量验证:59 通过、0 失败、1 项仅因本机未提供真实 Postgres 测试地址而跳过;真实 Railway staging migration、readiness 和截图 API 已另外验证。 +- iOS Debug 与 Release 全量验证各 52 通过、0 失败、0 跳过;每套包含 44 项逻辑测试和 8 条核心用户旅程 UI Test。 +- 空库跨页面返回、处理中继续导航/取消另一次上传、完整单卡刮开/自评/返回可用首页均以真实手势通过;13 张关键状态截图已沉淀到 [[docs/core-interaction-release-validation]]。 +- 提供的真实截图已通过系统照片选择器进入 Omo staging;同一自动化旅程完成首次许可、处理中杀 App、恢复、生成、刮开、自评和知识库确认。 +- 提供的真实截图已在 staging 成功生成卡片;另一次真实 App 上传遇到模型超时后进入明确失败态,同 ID 重试、入口可用和杀 App 恢复均已验证。 +- 独立 Omo `1.0 (3)` 已完成 Release Archive 与包审计;导出配置已从 internal-only 改为可外部 Beta Review,并新增回归门禁。 +- Share Extension 的首次 AI 授权跳转仍属于 PRD 第 17 节待产品确认边界,不在本次 P0 首页死锁修复中伪实现。 + +## 阻塞与解除条件(2026-08-09) + +- 阻塞 1:完成标准要求最终 TestFlight Release 真机通过同一份 PRD;当前 build 2 已为 `VALID / IN_BETA_TESTING`,但实体手机上的上传、杀进程恢复、刮开和自评手势必须由产品方实际操作确认,Agent 无法远程代替。 +- 阻塞 2:阶段 D 要求 Share Extension,但 PRD 第 17 节仍要求产品确认“首次未授权时先打开 Omo,再继续同一截图”的额外跳转是否可接受。没有该决定时不能安全确定扩展的授权与任务交接流程。 +- 解除条件:产品方反馈 build 2 的真机逐项结果,并确认 Share Extension 首次授权流程。收到反馈后恢复本计划;若发现问题则继续修复并发布新 build,全部通过后再进入 `completed` 与退役流程。 + +## 恢复记录(2026-08-09) + +- 产品方已恢复本计划,并将完成标准明确为完整用户旅程无死路,而不只是控件可点击。 +- 本轮必须把首次启动、上传、处理、恢复、失败、重试、知识库、抽卡、刮开、自评和下一张串成可复现的用户验收;发现死路必须先补失败测试再修复。 +- 最终构建必须进入独立 Omo 的外部 TestFlight 流程,不能只加入内部测试组;仍禁止修改 `main`、Railway production 或旧 Recallo。 + +## 合同冻结 + +- 输入:用户从 App 内照片选择器或后续 Share Extension 提交的一张截图。 +- 输出:可恢复的截图任务;成功时关联 canonical 记忆卡,失败时保留明确原因和重试动作。 +- Schema / API:截图任务拥有稳定 task ID、截图指纹、状态、失败语义、重试次数和最终 card ID;客户端不再依赖单次同步长请求表示整个生命周期。 +- 兼容要求:保留现有 `/api/sources/image-flow` 供旧构建使用;现有卡片和 assessment 行为不回归。 +- 失败语义:任何任务必须结束为 succeeded 或 failed,或在重启后继续呈现 accepted / processing;不允许静默消失。 + +## 分工 + +| 子任务 | 负责人 | 分支 / Worktree | 可写路径 | 验证 | 停止条件 | +|---|---|---|---|---|---| +| 全部实现与整合 | Codex `/root` | 当前分支 / 当前 Worktree | 本计划可写路径 | 计划第 5 节与完成标准 | 合同需要扩大、需要 production、或真实凭据缺失 | + +## 1. 已证实的结论 + +当前版本没有删除抽卡、刮刮乐或自评条。它们仍在当前代码中,并能在带有可复习卡片的数据状态下运行。 + +用户在 TestFlight 中遇到的主问题是:真实截图生成失败后,App 一直停留在“没有任何可复习卡”的空状态,因此抽卡、刮开和自评都不可达;同时空状态本身又错误地把收藏夹画成不可点击图片,并在上传期间禁用加号,最终形成“页面看起来有入口,但几乎全部不能点”的体验。 + +### 1.1 证据 + +1. 当前 Debug 构建注入开发卡片后,首页的 IP、收藏夹、上传均可点击。 +2. 点击 IP 后,当前构建能进入四层卡组;强制揭示状态下,自评 Slider、详情、收藏夹、上传入口均存在。 +3. 当前 `RecallRatingSlider.swift` 与上次成功验收提交 `cfa7b02` 无差异。 +4. 当前刮开组件仍保留局部遮罩、80% 阈值和完全揭示逻辑;相对上次成功提交只新增了“揭示后才能打开完整上下文”的限制。 +5. 上次成功验收明确使用 `OMO_DEMO_MODE=1` 和临时 Fixture 数据,不是 TestFlight 的真实空库上传链路。 +6. Omo staging 日志中,用户此前的 `POST /api/sources/image-flow` 在约 60.9 秒后返回 504;卡片没有保存。 +7. 本次用产品提供的真实截图再次请求 staging,18.7 秒成功生成卡片,说明服务不是必然失败,而是耗时与可靠性存在波动。 +8. 当前 37 个 XCTest 全部通过,但没有 UI Test target,也没有慢请求、504、上传期间点击、杀进程恢复等测试。 + +## 2. 问题清单与原因 + +### P0-1 空库收藏夹不可点击 + +- 表现:用户没有卡片时,首页收藏夹看起来像入口,但点击无响应。 +- 直接原因:空库分支使用普通 `Image("FirstLaunchFolder")`;只有有卡分支才使用 `Button`。 +- 为什么以前没发现:成功 Simulator 演示通常先注入了卡片,因此进入的是有卡分支。 +- 违反 PRD:收藏夹在空库、处理中和有卡时都必须可用。 + +### P0-2 上传期间加号被全局禁用 + +- 表现:选择第一张截图后,加号变灰且不能再次点击。 +- 直接原因:单一全局布尔值 `store.isCreating` 驱动 `.disabled(isCreating)`。 +- 深层原因:前端把“一个上传任务正在处理”错误建模成“整个产品正在创建”。 +- 违反 PRD:处理一张截图时,加号、收藏夹、菜单仍可用。 + +### P0-3 第一张截图请求是同步、长耗时且不可恢复的 + +- 表现:页面长时间显示“正在整理”;请求 504 后没有卡片;用户无法判断是处理、卡死还是失败。 +- 直接原因:客户端发起一次同步 `POST /api/sources/image-flow`,等待模型、溯源和数据库保存全部完成后才得到响应。 +- 服务侧证据:同一接口一次约 18.7 秒成功,另一次约 60.9 秒被网关以 504 终止。 +- 深层原因:耗时 AI 工作没有独立任务身份、持久状态和轮询接口。 + +### P0-4 处理状态只存在内存,杀 App 后静默消失 + +- 表现:后台杀掉 App 再打开,“正在处理”消失,卡片也不存在。 +- 直接原因:`isCreating` 只是 `OmoStore` 的内存状态;截图数据和处理任务均未持久化。 +- 深层原因:前后端都没有“已接收 / 处理中 / 成功 / 失败”的任务合同。 +- 违反 PRD:重新启动后必须恢复真实任务状态,不能静默丢失。 + +### P0-5 生成失败没有可见、可操作的恢复路径 + +- 表现:空库时最多出现一条短错误文字;没有针对该截图的重试项。已有卡时,从首页上传失败甚至看不到错误。 +- 直接原因:失败只写入全局 `store.message`;首页有卡分支不渲染 `statusText`,且没有处理任务模型或重试按钮。 +- 深层原因:错误被当成瞬时 Toast,而不是上传任务的终态。 + +### P0-6 IP 的可达性完全依赖成功生成卡片 + +- 表现:真实上传失败后,IP 永远不能进入抽卡;用户主观感受是“IP 也坏了”。 +- 直接原因:只有 `dueCards` 非空时 IP 才是 Button;这是合理前置条件,但页面没有解释“当前没有可抽卡片”的真实状态。 +- 判断:不是刮刮乐代码故障,而是上游没有产生可复习卡。 + +### P0-7 知识库没有处理中/失败任务 + +- 表现:用户无法去收藏夹确认截图是否仍在处理,也无法从失败项重试。 +- 直接原因:知识库只接收 `[MemoryCard]`,没有上传任务列表。 +- 深层原因:数据模型只有最终卡片,没有生成生命周期。 + +### P1-1 App 启动加载失败缺少明确状态与重试 + +- 表现:首次加载失败时可能先显示空库引导;没有独立“加载失败 + 重试”状态。 +- 直接原因:`isLoading` 和 `message` 没有形成首页状态机,`cards.isEmpty` 被同时用来表示“真实空库”和“尚未加载成功”。 + +### P1-2 两套上传界面行为不一致 + +- 表现:首页加号直接打开照片选择器;知识库加号先打开“添加内容”Sheet,再打开照片选择器,等待文案和失败呈现也不同。 +- 直接原因:首页和知识库分别实现了截图读取、许可和生成流程。 +- 风险:同一动作在不同入口产生不同状态和错误恢复方式,后续修复容易只覆盖一处。 + +### P1-3 通知卡叠加时没有保留持久首页入口 + +- 表现:通知卡以高层级覆盖首页;与普通复习轮次不同,它没有在同一层重新放置收藏夹和加号。 +- 直接原因:通知 `RecallRoundView` 单独以 `zIndex(20)` 叠加,持久入口位于其下方。 +- 违反 PRD:通知卡状态下菜单、收藏夹、加号仍应可用。 + +### P1-4 处理文案伪装成精确进度 + +- 表现:知识库上传 Sheet 显示“正在识别标题并通过 TickHub 核对来源”,但前端并不知道后端当前进行到哪一步。 +- 直接原因:同步接口不返回阶段事件,UI 使用固定文案猜测阶段。 +- 风险:卡住时用户以为某个阶段仍在工作,实际请求可能已经超时。 + +### P1-5 没有并行上传任务模型 + +- 表现:产品要求处理一张时仍可继续上传,但当前 Store 只能容纳一个 `isCreating`。 +- 直接原因:缺少基于 task ID 的任务数组和单任务状态。 + +### P1-6 Share Extension 尚未实现 + +- 表现:PRD 约定的主要日常入口——系统分享截图到 Omo——当前工程不存在对应 target。 +- 证据:Xcode 工程只有 `Omo` 与 `OmoTests` 两个 target。 +- 判断:这是 MVP 功能缺口,不是本次“首页死掉”的直接原因,但应在 TestFlight 核心流程完成前明确排期。 + +### P1-7 自动化验收只覆盖状态逻辑,没有覆盖真实点击链路 + +- 表现:37 个 XCTest 全部通过,真机仍出现核心入口不可用。 +- 直接原因:项目没有 UI Test target;创建卡测试使用立即返回的 Stub。 +- 深层原因:把 Store 单元测试、Fixture 截图和历史 Bundle 的结果当成最终构建交互验收。 + +### P2-1 空库/无到期卡时 IP 缺少明确的不可用反馈 + +- 表现:IP 保留视觉,但没有按钮语义;用户可能仍认为它应该能点。 +- 处理原则:不增加抢注意力的常驻文字;通过动画状态、触控反馈或上下文引导,让“现在不能抽”可理解。 +- 备注:具体 IP 形象和动画仍不在本轮决定。 + +## 3. 为什么看起来像“突然什么都没了” + +这不是一次单点 UI 删除,而是两个条件叠加: + +1. 以前成功的 Simulator 验收预置了卡片,直接进入“有卡”分支;因此 IP、收藏夹、卡组、刮刮乐和自评都能出现。 +2. TestFlight 从真实空库开始,第一张截图又在真实 AI 请求中超时;因此 App 一直停在有缺陷的“空库/处理中”分支。 + +空库分支从上次成功提交起就已经存在“收藏夹只是图片、加号处理时禁用”的问题。它不是这几天才被删坏,而是之前的验收数据没有触达这个分支。当前版本新增的 AI 许可和通知逻辑没有删除刮刮乐或自评条。 + +## 4. 推荐修复设计 + +### 4.1 第一原则 + +用户选择截图后,产品首先必须可靠地承认“我已经接住了这张截图”,然后 AI 才可以慢慢处理。不能让一次持续几十秒的网络请求同时承担“接收任务”和“生成完成”两种职责。 + +### 4.2 最小可靠任务模型 + +每张截图形成一个独立 `ScreenshotJob`: + +```text +localPreparing +→ accepted +→ processing +→ succeeded(cardID) +或 failed(retryable, message) +``` + +每个任务至少保存:task ID、截图指纹、创建时间、状态、失败原因、重试次数、最终 card ID。重复截图使用同一幂等键,避免重复卡。 + +### 4.3 前后端合同 + +推荐采用轻量异步任务,而不是继续延长同步超时: + +1. `POST /api/screenshot-jobs` 快速接收截图并返回 `202 + job`。 +2. Railway worker 在后台执行模型、溯源和保存。 +3. `GET /api/screenshot-jobs` 返回当前设备全部未完成与近期失败任务。 +4. `GET /api/screenshot-jobs/:id` 返回单任务状态。 +5. `POST /api/screenshot-jobs/:id/retry` 重试同一任务。 +6. 成功后任务关联 canonical `cardID`;卡片 ID 或幂等键继续以截图指纹保证去重。 + +如果为控制 MVP 复杂度需要分两步,第一步可以先在 iOS 本地持久化待上传截图并在重启后幂等重试;但这只能作为过渡方案,不能解决服务处理超过 Railway 请求时限的问题。因此最终仍推荐异步任务合同。 + +### 4.4 首页与知识库表现 + +- 空库收藏夹始终是 Button,可进入空知识库。 +- 加号不因其他任务处理中而禁用;只在系统选择器或许可弹窗自身活动时防重复触发。 +- 首页显示轻量任务摘要,不覆盖三个核心入口。 +- 知识库把处理中和失败项放在完整卡片之前;失败项提供重试。 +- IP 是否可抽只看 `dueCards`,与其他截图任务无关。 +- 有卡首页的上传失败必须有可见反馈。 +- App 启动先区分 loading、loadedEmpty、loadedWithCards、loadFailed,不把加载失败误当空库。 +- 通知卡叠加时,收藏夹和加号保持在可点击层。 +- 两个上传入口共用一个 Upload Coordinator,不再复制流程。 + +## 5. 实施顺序 + +### 阶段 A:先建立失败测试 + +1. 新增 `OmoUITests` target。 +2. 为首页建立可控 Fixture:空库、处理中、有卡、加载失败、生成失败。 +3. 添加 UI 测试:空库收藏夹可点;处理中菜单/收藏夹/加号可点;有旧卡时 IP 可点。 +4. 添加 Store 测试:慢请求不锁入口;失败进入 failed;重启恢复;重试复用 task ID。 +5. 添加后端合同测试:202 接收、状态轮询、超时失败、幂等重试、服务重启恢复。 + +### 阶段 B:修复 P0 入口和状态模型 + +1. 把空库收藏夹改为与有卡状态共用的持久 Button。 +2. 用 `[ScreenshotJob]` 替换全局 `isCreating`。 +3. 抽出统一 Upload Coordinator,首页和知识库共用。 +4. 首页与知识库增加 processing / failed / retry 展示。 +5. 增加独立 loading / loadFailed 状态和重试。 +6. 处理任务与卡片状态持久化并支持 App 重启恢复。 + +### 阶段 C:接入异步 staging 合同 + +1. 后端增加持久任务表和异步执行器。 +2. iOS 改为快速接收 + 轮询,不再等待同步 image-flow。 +3. 用截图指纹实现幂等和重复提交归并。 +4. Railway 只部署 staging,验证服务重启、模型超时和数据库恢复。 + +### 阶段 D:修复次级交互缺口 + +1. 通知卡叠层保留收藏夹、上传和菜单的可点击性。 +2. 删除假阶段文案,改成由真实任务状态驱动。 +3. 为无可复习卡的 IP 提供不抢注意力的状态反馈。 +4. 建立 Share Extension target,复用同一任务接收合同。 + +### 阶段 E:完整验收 + +1. 使用本次提供的截图验证真实 staging 成功、超时、失败和重试。 +2. Simulator 逐项实际点击空库、处理中、有卡、复习、通知、知识库。 +3. 验证 79%/80% 刮开、自评取消、三个自评节点、失败重试。 +4. 处理中切后台、杀 App、重新打开,确认任务不消失。 +5. 用与 TestFlight 相同的 Release Archive 在真机复测,不沿用 Debug Fixture 证据。 +6. 每个关键状态保存截图或录屏,并单列未验证项。 + +## 6. 本轮明确不重写的部分 + +- 当前刮刮乐的局部遮罩与 80% 阈值。 +- 当前自评条的完整渐变、固定色箭头、取消区和三个正式节点。 +- 当前四层卡组和回顾中保留收藏夹/上传的正常路径。 +- 当前知识卡揭示后的字重与强调色逻辑。 + +这些部分只有在真实交互验收发现具体失败后才做针对性修改,避免把上游不可达误诊为组件被删除。 + +## 7. 完成标准 + +只有同时满足以下条件,才可以再次声明“核心前端交互已修复”: + +1. 空库、处理中、有卡三个状态的核心入口逐个真实点击通过。 +2. 首张真实截图能结束为成功或明确失败,并能重试。 +3. 处理中杀 App 后状态可恢复。 +4. 成功卡在首页、知识库和重启后都存在,并能进入抽卡、刮开、自评。 +5. 自动测试覆盖慢请求、504、重启恢复和按钮可点击性。 +6. 最终 TestFlight Release 真机通过同一份 PRD 验收。 diff --git a/railway.json b/railway.json index 1deeacc..55db19e 100644 --- a/railway.json +++ b/railway.json @@ -1,11 +1,11 @@ { "$schema": "https://railway.com/railway.schema.json", "build": { - "builder": "RAILPACK", - "buildCommand": "npm --prefix backend install" + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile" }, "deploy": { - "startCommand": "npm --prefix backend start", + "preDeployCommand": "npm run db:migrate", "healthcheckPath": "/api/readiness", "healthcheckTimeout": 60 }