diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 263ed47b..c807ffb0 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -11,6 +11,15 @@ if ! command -v bun &> /dev/null; then exit 1 fi +# Run prettier check +echo "Checking code formatting with Prettier..." +if ! bun run format:check; then + echo "" + echo "Error: Code formatting issues found! Please run 'bun run format' to fix formatting issues." + echo "Run 'cd frontend && bun run format:check' to see the formatting issues." + exit 1 +fi + # Run the build command echo "Running bun build..." if ! bun run build; then @@ -20,5 +29,5 @@ if ! bun run build; then exit 1 fi -echo "Build successful! Proceeding with commit..." +echo "All checks passed! Proceeding with commit..." exit 0 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 90bbd56a..a76bb37f 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,8 @@ frontend/*.local *.tsbuildinfo **/.claude/settings.local.json + +.repo_ignore + +# iOS build backups +frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj.backup diff --git a/README.md b/README.md index 71e737d1..eddcbc12 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,18 @@ If there's a new version of the enclave pushed to staging or prod, append the ne ## iOS Development +Run in emulator: + +```bash +dotenv -e .env.local -- bun run tauri ios dev 'iPhone 16 Pro' +``` + +Run on a connected phone: + +```bash +dotenv -e .env.local -- bun run tauri ios build +``` + ### Ignoring Local XCode Project Changes To prevent committing automatic changes to the XCode project file during local development: diff --git a/docs/troubleshooting-ios-build.md b/docs/troubleshooting-ios-build.md new file mode 100644 index 00000000..168c08ce --- /dev/null +++ b/docs/troubleshooting-ios-build.md @@ -0,0 +1,73 @@ +# iOS Build Troubleshooting + +## arm64-sim Architecture Error + +### Problem +When building for iOS simulator, you may encounter this error: +``` +clang: error: version '-sim' in target triple 'arm64-apple-ios13.0-simulator-sim' is invalid +``` + +This happens when the Xcode project file incorrectly lists `arm64-sim` as an architecture, causing a duplicate `-sim` suffix in the target triple. + +### Solution + +1. **Edit the Xcode project file** (`frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj`): + + Find and replace all occurrences of: + ``` + ARCHS = ( + arm64, + "arm64-sim", + ); + ``` + + With: + ``` + ARCHS = ( + arm64, + x86_64, + ); + ``` + +2. **Update VALID_ARCHS**: + + Replace: + ``` + VALID_ARCHS = "arm64 arm64-sim"; + ``` + + With: + ``` + VALID_ARCHS = "arm64 x86_64"; + ``` + +3. **Update EXCLUDED_ARCHS**: + + Replace: + ``` + "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; + ``` + + With: + ``` + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + ``` + +### Important Notes + +- Keep the `arm64-sim` references in library search paths and output paths - these refer to directory names, not architectures +- This issue can reoccur if the Xcode project is regenerated +- Related to Xcode 16.3+ behavior changes with simulator architectures + +### Prevention + +To prevent this issue from recurring: + +1. Avoid regenerating the iOS project unless necessary +2. If you must regenerate, check the project.pbxproj file for incorrect `arm64-sim` architecture entries +3. Consider adding a post-generation script to automatically fix these entries + +### Reference + +This issue is tracked in [tauri-apps/tauri#12882](https://github.com/tauri-apps/tauri/issues/12882) \ No newline at end of file diff --git a/frontend/bun.lock b/frontend/bun.lock index ad1399f7..b864a253 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -4,7 +4,7 @@ "": { "name": "maple", "dependencies": { - "@opensecret/react": "1.3.6", + "@opensecret/react": "1.3.8", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", @@ -30,6 +30,7 @@ "react-markdown": "^9.0.1", "rehype-highlight": "^7.0.0", "rehype-katex": "^7.0.1", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", @@ -214,7 +215,7 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opensecret/react": ["@opensecret/react@1.3.6", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-YGAmUArtSCcqSmSSNlFERwfyBxyb5sMCmkCTXfuAcuQVNeBbw04qgrJk9RCv4hQ+KlxU9MlmFRZY74Fbn/O+Jg=="], + "@opensecret/react": ["@opensecret/react@1.3.8", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-FIkMvPaIWEKuDjffz2W0EPrwwoiWOTuBMMIXr7zvXBhDJ5HwCZIQBmm9bAMprxWYQrWAqdiy80iBAC7MKIGFiw=="], "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "@peculiar/asn1-x509-attr": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-B+DoudF+TCrxoJSTjjcY8Mmu+lbv8e7pXGWrhNp2/EGJp9EEcpzjBCar7puU57sGifyzaRVM03oD5L7t7PghQg=="], @@ -730,6 +731,8 @@ "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.2", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "style-to-object": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg=="], "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], @@ -1032,6 +1035,8 @@ "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], + "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], diff --git a/frontend/package.json b/frontend/package.json index a28e1d7d..e829fac9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "mdast-util-gfm-autolink-literal": "2.0.0" }, "dependencies": { - "@opensecret/react": "1.3.6", + "@opensecret/react": "1.3.8", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", @@ -42,6 +42,7 @@ "react-markdown": "^9.0.1", "rehype-highlight": "^7.0.0", "rehype-katex": "^7.0.1", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", diff --git a/frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj.backup b/frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj.backup new file mode 100644 index 00000000..41a99a89 --- /dev/null +++ b/frontend/src-tauri/gen/apple/maple.xcodeproj/project.pbxproj.backup @@ -0,0 +1,626 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 0F4AA8C5111094B8D7B033A4 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E996CAD20B045EF299247BEF /* WebKit.framework */; }; + 23D760C25DBAAA96DF52F98C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AAFD3E024B29F57EA0D88AB5 /* MetalKit.framework */; }; + 27222ACBBD95CA356BDB87AB /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BAF092EB33F2B4FDF867E2ED /* QuartzCore.framework */; }; + 46728F6CD07C626A781543FF /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 938F41BA03AF7FAB80585A60 /* Security.framework */; }; + 4E14944735CA89389849E430 /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F27A6FC4AB3E06D481B1E137 /* libapp.a */; }; + 621A96F6B965E600E714FB6C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B0EAFEFF59AA7CFEF57FA63C /* LaunchScreen.storyboard */; }; + 8F9EDB5679AB5E12D6F1E071 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DACD0B6B9FB86AFF47366EB2 /* UIKit.framework */; }; + 90DEA2E2EC5F95784C9D8B00 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 898D6E7ABC92C3B9A7970DAE /* CoreGraphics.framework */; }; + 9F3ED7EA97AFC22E0B4A6EAF /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = CE20E8A9C4CF149CA9CA485A /* main.mm */; }; + A120CF155DE2B343CEFFB77C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6BF4C7EB12DBD2E253B2741E /* Assets.xcassets */; }; + B79EC8A2A1A869B9F38906ED /* assets in Resources */ = {isa = PBXBuildFile; fileRef = AF5DA3546C7B12BA141E9508 /* assets */; }; + FB98D58EAB479A32F7CA66CA /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C072EB8E1CCC1094712EA7F7 /* Metal.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1094B8CB671809878F533CEC /* lib.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = lib.rs; sourceTree = ""; }; + 19C98BDFCAE8C0B2FE05F13B /* maple_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = maple_iOS.entitlements; sourceTree = ""; }; + 1DB07799A17353B2E32B65D3 /* Maple.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Maple.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 6BF4C7EB12DBD2E253B2741E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 7BBAB8D5C4E3D05A4F48B6EA /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = ""; }; + 898D6E7ABC92C3B9A7970DAE /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + 938F41BA03AF7FAB80585A60 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + AAFD3E024B29F57EA0D88AB5 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; + AF5DA3546C7B12BA141E9508 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; }; + B0EAFEFF59AA7CFEF57FA63C /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + BAF092EB33F2B4FDF867E2ED /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + C072EB8E1CCC1094712EA7F7 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; + C0A332F21EB65CD4B83229DF /* main.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = main.rs; sourceTree = ""; }; + CE20E8A9C4CF149CA9CA485A /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; + D6F6085362AF0C062EDB5810 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + DACD0B6B9FB86AFF47366EB2 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + E996CAD20B045EF299247BEF /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + F27A6FC4AB3E06D481B1E137 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 04F3BEBB04BD927FDC86B255 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4E14944735CA89389849E430 /* libapp.a in Frameworks */, + 90DEA2E2EC5F95784C9D8B00 /* CoreGraphics.framework in Frameworks */, + FB98D58EAB479A32F7CA66CA /* Metal.framework in Frameworks */, + 23D760C25DBAAA96DF52F98C /* MetalKit.framework in Frameworks */, + 27222ACBBD95CA356BDB87AB /* QuartzCore.framework in Frameworks */, + 46728F6CD07C626A781543FF /* Security.framework in Frameworks */, + 8F9EDB5679AB5E12D6F1E071 /* UIKit.framework in Frameworks */, + 0F4AA8C5111094B8D7B033A4 /* WebKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 31943D18956B6AFD5A82AB02 /* maple_iOS */ = { + isa = PBXGroup; + children = ( + D6F6085362AF0C062EDB5810 /* Info.plist */, + 19C98BDFCAE8C0B2FE05F13B /* maple_iOS.entitlements */, + ); + path = maple_iOS; + sourceTree = ""; + }; + 6AE8986BFC081ADF4EF98889 /* Externals */ = { + isa = PBXGroup; + children = ( + ); + path = Externals; + sourceTree = ""; + }; + A278899D6905367F60E00F01 /* Sources */ = { + isa = PBXGroup; + children = ( + E5992A2C52DE443515252068 /* maple */, + ); + path = Sources; + sourceTree = ""; + }; + B58B2AB62FA5D8F41C3A031C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 898D6E7ABC92C3B9A7970DAE /* CoreGraphics.framework */, + F27A6FC4AB3E06D481B1E137 /* libapp.a */, + C072EB8E1CCC1094712EA7F7 /* Metal.framework */, + AAFD3E024B29F57EA0D88AB5 /* MetalKit.framework */, + BAF092EB33F2B4FDF867E2ED /* QuartzCore.framework */, + 938F41BA03AF7FAB80585A60 /* Security.framework */, + DACD0B6B9FB86AFF47366EB2 /* UIKit.framework */, + E996CAD20B045EF299247BEF /* WebKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + C312B5C9A1506139452E194B /* Products */ = { + isa = PBXGroup; + children = ( + 1DB07799A17353B2E32B65D3 /* Maple.app */, + ); + name = Products; + sourceTree = ""; + }; + D3140E092D76240F6A157CCE = { + isa = PBXGroup; + children = ( + AF5DA3546C7B12BA141E9508 /* assets */, + 6BF4C7EB12DBD2E253B2741E /* Assets.xcassets */, + B0EAFEFF59AA7CFEF57FA63C /* LaunchScreen.storyboard */, + 6AE8986BFC081ADF4EF98889 /* Externals */, + 31943D18956B6AFD5A82AB02 /* maple_iOS */, + A278899D6905367F60E00F01 /* Sources */, + FF31B258F4FE6CC5B57B4FB8 /* src */, + B58B2AB62FA5D8F41C3A031C /* Frameworks */, + C312B5C9A1506139452E194B /* Products */, + ); + sourceTree = ""; + }; + E5992A2C52DE443515252068 /* maple */ = { + isa = PBXGroup; + children = ( + CE20E8A9C4CF149CA9CA485A /* main.mm */, + F9E1B0C7AF484C8F6312F2C8 /* bindings */, + ); + path = maple; + sourceTree = ""; + }; + F9E1B0C7AF484C8F6312F2C8 /* bindings */ = { + isa = PBXGroup; + children = ( + 7BBAB8D5C4E3D05A4F48B6EA /* bindings.h */, + ); + path = bindings; + sourceTree = ""; + }; + FF31B258F4FE6CC5B57B4FB8 /* src */ = { + isa = PBXGroup; + children = ( + 1094B8CB671809878F533CEC /* lib.rs */, + C0A332F21EB65CD4B83229DF /* main.rs */, + ); + name = src; + path = ../../src; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 745812EBA5246F9E4CEE1574 /* maple_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 27939507295816FC37580B40 /* Build configuration list for PBXNativeTarget "maple_iOS" */; + buildPhases = ( + 88FF8D973FB9516A2A98CD39 /* Build Rust Code */, + 6D9F08407F22BE108C77276C /* Sources */, + 25104604B2C2A81B04543E68 /* Resources */, + 04F3BEBB04BD927FDC86B255 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = maple_iOS; + productName = maple_iOS; + productReference = 1DB07799A17353B2E32B65D3 /* Maple.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + EAA0B11A781B9CD753D1399F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + }; + buildConfigurationList = 2FA6FD4DEFE659C96AAB5E5D /* Build configuration list for PBXProject "maple" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = D3140E092D76240F6A157CCE; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 745812EBA5246F9E4CEE1574 /* maple_iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 25104604B2C2A81B04543E68 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A120CF155DE2B343CEFFB77C /* Assets.xcassets in Resources */, + 621A96F6B965E600E714FB6C /* LaunchScreen.storyboard in Resources */, + B79EC8A2A1A869B9F38906ED /* assets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 88FF8D973FB9516A2A98CD39 /* Build Rust Code */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Rust Code"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a", + "$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a", + "$(SRCROOT)/Externals/arm64-sim/${CONFIGURATION}/libapp.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "bun tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 6D9F08407F22BE108C77276C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9F3ED7EA97AFC22E0B4A6EAF /* main.mm in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1DE1BEC252AAEF735A07CA8B /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + "arm64-sim", + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = maple_iOS/maple_iOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "X773Y823TN"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = maple_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64-sim]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64-sim/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple; + PRODUCT_NAME = Maple; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = "arm64 arm64-sim"; + }; + name = debug; + }; + 1E8FE8E2E3063C3FDCD3B5D1 /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = debug; + }; + 94ECC2E044AA76E166E0866E /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = release; + }; + B90C9BEF4885680BFA986CD2 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + "arm64-sim", + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = maple_iOS/maple_iOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "X773Y823TN"; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = maple_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64-sim]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64-sim/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple; + PRODUCT_NAME = Maple; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = "arm64 arm64-sim"; + }; + name = release; + }; + EA2669D02DADAA11005A7F4B /* local */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = local; + }; + EA2669D12DADAA11005A7F4B /* local */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ARCHS = ( + arm64, + "arm64-sim", + ); + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = maple_iOS/maple_iOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = "X773Y823TN"; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = X773Y823TN; + ENABLE_BITCODE = NO; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\".\"", + ); + INFOPLIST_FILE = maple_iOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64-sim]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64-sim/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION)", + "$(SDKROOT)/usr/lib/swift", + "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", + ); + PRODUCT_BUNDLE_IDENTIFIER = cloud.opensecret.maple; + PRODUCT_NAME = Maple; + PROVISIONING_PROFILE_SPECIFIER = "86059ea7-ae8e-44af-8a58-b2ab7c78d299"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "86059ea7-ae8e-44af-8a58-b2ab7c78d299"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALID_ARCHS = "arm64 arm64-sim"; + }; + name = local; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 27939507295816FC37580B40 /* Build configuration list for PBXNativeTarget "maple_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1DE1BEC252AAEF735A07CA8B /* debug */, + EA2669D12DADAA11005A7F4B /* local */, + B90C9BEF4885680BFA986CD2 /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; + 2FA6FD4DEFE659C96AAB5E5D /* Build configuration list for PBXProject "maple" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1E8FE8E2E3063C3FDCD3B5D1 /* debug */, + EA2669D02DADAA11005A7F4B /* local */, + 94ECC2E044AA76E166E0866E /* release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = EAA0B11A781B9CD753D1399F /* Project object */; +} diff --git a/frontend/src-tauri/gen/apple/maple_iOS/Info.plist b/frontend/src-tauri/gen/apple/maple_iOS/Info.plist index a7112f82..f1b651cf 100644 --- a/frontend/src-tauri/gen/apple/maple_iOS/Info.plist +++ b/frontend/src-tauri/gen/apple/maple_iOS/Info.plist @@ -58,5 +58,9 @@ ITSAppUsesNonExemptEncryption + NSPhotoLibraryUsageDescription + Maple needs access to your photo library to upload images to your AI conversations. + NSCameraUsageDescription + Maple needs access to your camera to take photos for your AI conversations. \ No newline at end of file diff --git a/frontend/src/components/ChatBox.tsx b/frontend/src/components/ChatBox.tsx index d3387245..88cba304 100644 --- a/frontend/src/components/ChatBox.tsx +++ b/frontend/src/components/ChatBox.tsx @@ -1,6 +1,12 @@ -import { CornerRightUp, Bot } from "lucide-react"; +import { CornerRightUp, Bot, Image, X, FileText, Loader2, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; import { useEffect, useRef, useState } from "react"; import { useLocalState } from "@/state/useLocalState"; import { cn, useIsMobile } from "@/utils/utils"; @@ -10,7 +16,24 @@ import { BillingStatus } from "@/billing/billingApi"; import { Route as ChatRoute } from "@/routes/_auth.chat.$chatId"; import { ChatMessage } from "@/state/LocalStateContext"; import { useNavigate, useRouter } from "@tanstack/react-router"; -import { ModelSelector } from "@/components/ModelSelector"; +import { ModelSelector, MODEL_CONFIG } from "@/components/ModelSelector"; +import { useOpenSecret } from "@opensecret/react"; +import type { DocumentResponse } from "@opensecret/react"; + +interface ParsedDocument { + document: { + filename: string; + md_content: string | null; + json_content: string | null; + html_content: string | null; + text_content: string | null; + doctags_content: string | null; + }; + status: string; + errors: unknown[]; + processing_time: number; + timings: Record; +} // Rough token estimation function function estimateTokenCount(text: string): number { @@ -36,8 +59,23 @@ function TokenWarning({ isCompressing?: boolean; }) { const totalTokens = - messages.reduce((acc, msg) => acc + estimateTokenCount(msg.content), 0) + - (currentInput ? estimateTokenCount(currentInput) : 0); + messages.reduce((acc, msg) => { + if (typeof msg.content === "string") { + return acc + estimateTokenCount(msg.content); + } else { + // For multimodal content, estimate tokens from text parts + return ( + acc + + msg.content.reduce((sum, part) => { + if (part.type === "text") { + return sum + estimateTokenCount(part.text); + } + // Rough estimate for images + return sum + 85; + }, 0) + ); + } + }, 0) + (currentInput ? estimateTokenCount(currentInput) : 0); const navigate = useNavigate(); @@ -115,20 +153,279 @@ export default function Component({ messages = [], isStreaming = false, onCompress, - isSummarizing = false + isSummarizing = false, + imageConversionError }: { - onSubmit: (input: string, systemPrompt?: string) => void; + onSubmit: ( + input: string, + systemPrompt?: string, + images?: File[], + documentText?: string, + documentMetadata?: { filename: string; fullContent: string } + ) => void; startTall?: boolean; messages?: ChatMessage[]; isStreaming?: boolean; onCompress?: () => void; isSummarizing?: boolean; + imageConversionError?: string | null; }) { const [inputValue, setInputValue] = useState(""); const [systemPromptValue, setSystemPromptValue] = useState(""); const [isSystemPromptExpanded, setIsSystemPromptExpanded] = useState(false); - const { billingStatus, setBillingStatus, draftMessages, setDraftMessage, clearDraftMessage } = - useLocalState(); + const { + billingStatus, + setBillingStatus, + draftMessages, + setDraftMessage, + clearDraftMessage, + model, + setModel, + availableModels + } = useLocalState(); + + const isGemma = MODEL_CONFIG[model]?.supportsVision || false; + const [images, setImages] = useState([]); + const [imageUrls, setImageUrls] = useState>(new Map()); + const [uploadedDocument, setUploadedDocument] = useState<{ + original: DocumentResponse; + parsed: ParsedDocument; + cleanedText: string; + } | null>(null); + const [isUploadingDocument, setIsUploadingDocument] = useState(false); + const [documentError, setDocumentError] = useState(null); + const [imageError, setImageError] = useState(null); + const fileInputRef = useRef(null); + const documentInputRef = useRef(null); + const os = useOpenSecret(); + const navigate = useNavigate(); + + // Find the first vision-capable model the user has access to + const findFirstVisionModel = () => { + // Check if user has Pro/Team access + if (!hasProTeamAccess) return null; + + // Find first model that supports vision + for (const modelId of availableModels.map((m) => m.id)) { + const modelConfig = MODEL_CONFIG[modelId]; + if (modelConfig?.supportsVision) { + // Check if user has access to this model + const needsStarter = modelConfig.requiresStarter; + const needsPro = modelConfig.requiresPro; + + // If no special requirements, or user meets requirements + if (!needsStarter && !needsPro) return modelId; + if ( + needsStarter && + (freshBillingStatus?.product_name?.toLowerCase().includes("starter") || + freshBillingStatus?.product_name?.toLowerCase().includes("pro") || + freshBillingStatus?.product_name?.toLowerCase().includes("team")) + ) { + return modelId; + } + if (needsPro && hasProTeamAccess) return modelId; + } + } + return null; + }; + + const handleAddImages = (e: React.ChangeEvent) => { + if (!e.target.files) return; + + const supportedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; + const maxSizeInBytes = 5 * 1024 * 1024; // 5MB for images + const errors: string[] = []; + + const validFiles = Array.from(e.target.files).filter((file) => { + // Check file type + if (!supportedTypes.includes(file.type.toLowerCase())) { + return false; + } + + // Check file size + if (file.size > maxSizeInBytes) { + const sizeInMB = (file.size / (1024 * 1024)).toFixed(2); + errors.push(`${file.name} is too large (${sizeInMB}MB)`); + return false; + } + + return true; + }); + + if (validFiles.length < e.target.files.length) { + const skippedCount = e.target.files.length - validFiles.length; + const typeErrors = e.target.files.length - validFiles.length - errors.length; + + if (errors.length > 0) { + setImageError(`${errors.join(", ")}. Max size is 5MB per image.`); + } else if (typeErrors > 0) { + setImageError( + `${skippedCount} file(s) skipped. Only JPEG, PNG, and WebP images are supported.` + ); + } + // Clear error after 5 seconds + setTimeout(() => setImageError(null), 5000); + } else { + setImageError(null); + } + + // Create object URLs for the new images + const newUrlMap = new Map(imageUrls); + validFiles.forEach((file) => { + if (!newUrlMap.has(file)) { + newUrlMap.set(file, URL.createObjectURL(file)); + } + }); + setImageUrls(newUrlMap); + setImages((prev) => [...prev, ...validFiles]); + }; + + const removeImage = (idx: number) => { + setImages((prev) => { + const fileToRemove = prev[idx]; + // Revoke the object URL when removing the image + const url = imageUrls.get(fileToRemove); + if (url) { + URL.revokeObjectURL(url); + setImageUrls((prevUrls) => { + const newUrls = new Map(prevUrls); + newUrls.delete(fileToRemove); + return newUrls; + }); + } + return prev.filter((_, i) => i !== idx); + }); + // Clear any image errors when removing images + setImageError(null); + }; + + // Helper function to read text file and format as ParsedDocument + const processTextFileLocally = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + const content = event.target?.result as string; + + // Create a ParsedDocument structure matching the expected format + const parsedDocument: ParsedDocument = { + document: { + filename: file.name, + md_content: null, + json_content: null, + html_content: null, + text_content: content, + doctags_content: null + }, + status: "completed", + errors: [], + processing_time: 0, + timings: {} + }; + + resolve(parsedDocument); + }; + + reader.onerror = () => { + reject(new Error("Failed to read file")); + }; + + reader.readAsText(file); + }); + }; + + const handleDocumentUpload = async (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + + const file = e.target.files[0]; + + // Check file size (5MB limit = 1024 * 1024 bytes) + const maxSizeInBytes = 5 * 1024 * 1024; // 5MB + if (file.size > maxSizeInBytes) { + const sizeInMB = (file.size / (1024 * 1024)).toFixed(2); + setDocumentError(`File too large (${sizeInMB}MB). Maximum size is 5MB.`); + e.target.value = ""; // Reset input + return; + } + + setIsUploadingDocument(true); + setDocumentError(null); + + try { + let parsed: ParsedDocument; + let result: DocumentResponse | undefined; + + // Check if it's a text file (.txt or .md) + if (file.type === "text/plain" || file.name.endsWith(".txt") || file.name.endsWith(".md")) { + // Process text files locally + parsed = await processTextFileLocally(file); + } else { + // Upload other document types to the processing endpoint + result = await os.uploadDocumentWithPolling(file); + // Parse the JSON response + parsed = JSON.parse(result.text) as ParsedDocument; + } + + // Extract content with fallbacks (currently not used since we pass the full JSON) + // const content = + // parsed.document.md_content || + // parsed.document.json_content || + // parsed.document.html_content || + // parsed.document.text_content || + // parsed.document.doctags_content || + // ""; + + // Create a cleaned version of the parsed document with image tags stripped from md_content + const cleanedParsed = { + ...parsed, + document: { + ...parsed.document, + md_content: parsed.document.md_content + ? parsed.document.md_content.replace(/!\[Image\]\([^)]+\)/g, "") + : parsed.document.md_content + } + }; + + // For locally processed text files, create a mock original response + const originalResponse = + file.type === "text/plain" || file.name.endsWith(".txt") || file.name.endsWith(".md") + ? ({ + text: JSON.stringify(parsed), + filename: file.name, + size: file.size + } as DocumentResponse) + : result!; + + setUploadedDocument({ + original: originalResponse, + parsed: parsed, + cleanedText: JSON.stringify(cleanedParsed) // Store the cleaned JSON as a string + }); + } catch (error) { + console.error("Document upload failed:", error); + if (error instanceof Error) { + if (error.message.includes("exceeds maximum limit")) { + setDocumentError("File too large. Maximum size is 5MB."); + } else if (error.message.includes("401")) { + setDocumentError("Authentication required. Please log in to upload documents."); + } else if (error.message.includes("403")) { + setDocumentError("Usage limit exceeded. Please upgrade your plan."); + } else { + setDocumentError("Failed to process document. Please try again."); + } + } else { + setDocumentError("An unexpected error occurred."); + } + } finally { + setIsUploadingDocument(false); + if (e.target) e.target.value = ""; + } + }; + + const removeDocument = () => { + setUploadedDocument(null); + setDocumentError(null); + }; const [isFocused, setIsFocused] = useState(false); const inputRef = useRef(null); const systemPromptRef = useRef(null); @@ -162,9 +459,20 @@ export default function Component({ // Check if system prompt can be edited (only for new chats) const canEditSystemPrompt = canUseSystemPrompt && messages.length === 0; + // Check if user has access to Pro/Team features (Pro or Team plan) + const hasProTeamAccess = + freshBillingStatus && + (freshBillingStatus.product_name?.toLowerCase().includes("pro") || + freshBillingStatus.product_name?.toLowerCase().includes("team")); + + const canUseDocuments = hasProTeamAccess; + const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); - if (!inputValue.trim() || isSubmitDisabled) return; + + // Allow submission if there's text input, images, or a document + const hasContent = inputValue.trim() || images.length > 0 || uploadedDocument; + if (!hasContent || isSubmitDisabled) return; // Clear the drafts when submitting if (chatId) { @@ -180,16 +488,75 @@ export default function Component({ // Only pass system prompt if this is the first message const isFirstMessage = messages.length === 0; - onSubmit(inputValue.trim(), isFirstMessage ? systemPromptValue.trim() || undefined : undefined); + onSubmit( + inputValue.trim(), + isFirstMessage ? systemPromptValue.trim() || undefined : undefined, + images, + uploadedDocument?.cleanedText, // Now contains the full JSON with cleaned md_content + uploadedDocument + ? { + filename: uploadedDocument.parsed.document.filename, + fullContent: + uploadedDocument.parsed.document.md_content || + uploadedDocument.parsed.document.json_content || + uploadedDocument.parsed.document.html_content || + uploadedDocument.parsed.document.text_content || + uploadedDocument.parsed.document.doctags_content || + "" + } + : undefined + ); setInputValue(""); + // Clean up image URLs when clearing images + imageUrls.forEach((url) => URL.revokeObjectURL(url)); + setImageUrls(new Map()); + setImages([]); + + setUploadedDocument(null); + setDocumentError(null); + setImageError(null); + // Re-focus input after submitting setTimeout(() => { inputRef.current?.focus(); }, 0); }; - // Keep currentInputRef in sync with inputValue + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + if (isMobile || e.shiftKey || isStreaming) { + // On mobile, when Shift is pressed, or when streaming, allow newline + return; + } else if (isSubmitDisabled || !inputValue.trim()) { + // Prevent form submission when disabled or empty input + e.preventDefault(); + return; + } else { + // On desktop without Shift and not streaming, submit the form + e.preventDefault(); + handleSubmit(); + } + } + }; + + // Auto-resize effect for main input + useEffect(() => { + if (inputRef.current) { + inputRef.current.style.height = "auto"; + inputRef.current.style.height = `${inputRef.current.scrollHeight}px`; + } + }, [inputValue]); + + // Auto-resize effect for system prompt + useEffect(() => { + if (systemPromptRef.current) { + systemPromptRef.current.style.height = "auto"; + systemPromptRef.current.style.height = `${systemPromptRef.current.scrollHeight}px`; + } + }, [systemPromptValue]); + + // Update current input ref when input value changes useEffect(() => { currentInputRef.current = inputValue; }, [inputValue]); @@ -230,42 +597,9 @@ export default function Component({ } } - // Update previous chat id reference + // 3. Update the previous chat ID previousChatIdRef.current = chatId; - }, [chatId, draftMessages, setDraftMessage, clearDraftMessage, canEditSystemPrompt, messages]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - if (isMobile || e.shiftKey || isStreaming) { - // On mobile, when Shift is pressed, or when streaming, allow newline - return; - } else if (isSubmitDisabled || !inputValue.trim()) { - // Prevent form submission when disabled or empty input - e.preventDefault(); - return; - } else { - // On desktop without Shift and not streaming, submit the form - e.preventDefault(); - handleSubmit(); - } - } - }; - - // Auto-resize effect for main input - useEffect(() => { - if (inputRef.current) { - inputRef.current.style.height = "auto"; - inputRef.current.style.height = `${inputRef.current.scrollHeight}px`; - } - }, [inputValue]); - - // Auto-resize effect for system prompt - useEffect(() => { - if (systemPromptRef.current) { - systemPromptRef.current.style.height = "auto"; - systemPromptRef.current.style.height = `${systemPromptRef.current.scrollHeight}px`; - } - }, [systemPromptValue]); + }, [chatId, draftMessages, setDraftMessage, clearDraftMessage]); // Determine when the submit button should be disabled const isSubmitDisabled = @@ -301,6 +635,14 @@ export default function Component({ return () => clearTimeout(timer); }, [chatId, isStreaming, isInputDisabled]); // Re-run when chat ID changes, streaming completes, or input state changes + // Cleanup effect for object URLs + useEffect(() => { + return () => { + // Revoke all object URLs when component unmounts + imageUrls.forEach((url) => URL.revokeObjectURL(url)); + }; + }, [imageUrls]); + // No longer need token calculation or plan type check since we removed the hard limit // Just keeping the TokenWarning component which handles its own calculations const placeholderText = (() => { @@ -332,6 +674,8 @@ export default function Component({ onClick={() => setIsSystemPromptExpanded(!isSystemPromptExpanded)} className="flex items-center gap-1.5 text-xs font-medium transition-colors text-muted-foreground hover:text-foreground cursor-pointer" title="System Prompt" + aria-label="Toggle system prompt" + aria-expanded={isSystemPromptExpanded} > {systemPromptValue.trim() && ( @@ -376,6 +720,70 @@ export default function Component({ } }} > + {(images.length > 0 || + uploadedDocument || + isUploadingDocument || + documentError || + imageError || + imageConversionError) && ( +
+ {images.length > 0 && ( +
+ {images.map((f, i) => ( +
+ {`Uploaded + +
+ ))} +
+ )} + {(imageError || imageConversionError) && ( +
+ {imageError || imageConversionError} +
+ )} + {isUploadingDocument && !uploadedDocument && ( +
+ + + Processing document securely... This may take a minute. + +
+ )} + {uploadedDocument && ( +
+ + + {uploadedDocument.parsed.document.filename} + + +
+ )} + {documentError && ( +
+ {documentError} +
+ )} +
+ )} @@ -408,12 +816,114 @@ export default function Component({ onChange={(e) => setInputValue(e.target.value)} />
- + + + {/* Hidden file inputs */} + + + + {/* Consolidated upload button - show for all users */} + {!uploadedDocument && ( + + + + + + { + if (!hasProTeamAccess) { + navigate({ to: "/pricing" }); + } else { + // If not on a vision model, switch to one first + if (!isGemma) { + const visionModelId = findFirstVisionModel(); + if (visionModelId) { + setModel(visionModelId); + } + } + fileInputRef.current?.click(); + } + }} + className={cn( + "flex items-center gap-2 group", + !hasProTeamAccess && "hover:bg-purple-50 dark:hover:bg-purple-950/20" + )} + > + + Upload Images + {!hasProTeamAccess && ( + <> + + Pro + + + Upgrade? + + + )} + + { + if (!canUseDocuments) { + navigate({ to: "/pricing" }); + } else { + documentInputRef.current?.click(); + } + }} + className={cn( + "flex items-center gap-2 group", + !canUseDocuments && "hover:bg-purple-50 dark:hover:bg-purple-950/20" + )} + > + + Upload Document + {!canUseDocuments && ( + <> + + Pro + + + Upgrade? + + + )} + + + + )} + diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index b951b757..adc3bda4 100644 --- a/frontend/src/components/ChatHistoryList.tsx +++ b/frontend/src/components/ChatHistoryList.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useLocalState } from "@/state/useLocalState"; import { Link } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -42,34 +42,40 @@ export function ChatHistoryList({ currentChatId, searchQuery = "" }: ChatHistory return chats.filter((chat) => chat.title.toLowerCase().includes(normalizedQuery)); }, [chats, searchQuery]); - const handleDeleteChat = async (chatId: string) => { - try { - await deleteChat(chatId); - } catch (error) { - console.error("Error deleting chat:", error); - } - queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); - if (chatId === currentChatId) { - navigate({ to: "/" }); - } - }; + const handleDeleteChat = useCallback( + async (chatId: string) => { + try { + await deleteChat(chatId); + } catch (error) { + console.error("Error deleting chat:", error); + } + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + if (chatId === currentChatId) { + navigate({ to: "/" }); + } + }, + [deleteChat, queryClient, currentChatId, navigate] + ); - const handleOpenRenameDialog = (chat: { id: string; title: string }) => { + const handleOpenRenameDialog = useCallback((chat: { id: string; title: string }) => { setSelectedChat(chat); setIsRenameDialogOpen(true); - }; + }, []); - const handleRenameChat = async (chatId: string, newTitle: string) => { - try { - await renameChat(chatId, newTitle); - // Invalidate both the chat history list and the specific chat - queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); - queryClient.invalidateQueries({ queryKey: ["chat", chatId] }); - } catch (error) { - console.error("Error renaming chat:", error); - throw error; - } - }; + const handleRenameChat = useCallback( + async (chatId: string, newTitle: string) => { + try { + await renameChat(chatId, newTitle); + // Invalidate both the chat history list and the specific chat + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + queryClient.invalidateQueries({ queryKey: ["chat", chatId] }); + } catch (error) { + console.error("Error renaming chat:", error); + throw error; + } + }, + [renameChat, queryClient] + ); if (error) { return
{error.message}
; diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx index 25a1e9e4..cd6906bf 100644 --- a/frontend/src/components/ModelSelector.tsx +++ b/frontend/src/components/ModelSelector.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, Check, Lock } from "lucide-react"; +import { ChevronDown, Check, Lock, Camera } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -13,16 +13,16 @@ import { useNavigate } from "@tanstack/react-router"; import type { Model } from "openai/resources/models.js"; // Model configuration for display names and badges -const MODEL_CONFIG: Record< - string, - { - displayName: string; - badge?: string; - disabled?: boolean; - requiresPro?: boolean; - requiresStarter?: boolean; - } -> = { +type ModelCfg = { + displayName: string; + badge?: string; + disabled?: boolean; + requiresPro?: boolean; + requiresStarter?: boolean; + supportsVision?: boolean; +}; + +export const MODEL_CONFIG: Record = { "ibnzterrell/Meta-Llama-3.3-70B-Instruct-AWQ-INT4": { displayName: "Llama 3.3 70B" }, @@ -34,7 +34,8 @@ const MODEL_CONFIG: Record< "leon-se/gemma-3-27b-it-fp8-dynamic": { displayName: "Gemma 3 27B", badge: "Starter", - requiresStarter: true + requiresStarter: true, + supportsVision: true }, "deepseek-r1-70b": { displayName: "DeepSeek R1 70B", @@ -43,7 +44,15 @@ const MODEL_CONFIG: Record< } }; -export function ModelSelector() { +import { ChatMessage } from "@/state/LocalStateContextDef"; + +export function ModelSelector({ + messages = [], + draftImages = [] +}: { + messages?: ChatMessage[]; + draftImages?: File[]; +}) { const { model, setModel, availableModels, setAvailableModels, billingStatus } = useLocalState(); const os = useOpenSecret(); const navigate = useNavigate(); @@ -51,6 +60,14 @@ export function ModelSelector() { const hasFetched = useRef(false); const availableModelsRef = useRef(availableModels); + // Check if chat contains any images or if there are draft images + const chatHasImages = + draftImages.length > 0 || + messages.some( + (msg) => + typeof msg.content !== "string" && msg.content.some((part) => part.type === "image_url") + ); + // Keep ref updated useEffect(() => { availableModelsRef.current = availableModels; @@ -170,6 +187,10 @@ export function ModelSelector() { ) { elements.push(); } + + if (config.supportsVision) { + elements.push(); + } } else { // Unknown models: show model ID with "Coming Soon" badge const model = availableModels.find((m) => m.id === modelId); @@ -205,14 +226,23 @@ export function ModelSelector() { - + {availableModels && Array.isArray(availableModels) && - // Sort models: available first, then restricted (pro-only), then disabled + // Sort models: vision-capable first (if images present), then available, then restricted, then disabled [...availableModels] .sort((a, b) => { const aConfig = MODEL_CONFIG[a.id]; const bConfig = MODEL_CONFIG[b.id]; + + // If chat has images, prioritize vision models + if (chatHasImages) { + const aHasVision = aConfig?.supportsVision || false; + const bHasVision = bConfig?.supportsVision || false; + if (aHasVision && !bHasVision) return -1; + if (!aHasVision && bHasVision) return 1; + } + // Unknown models are treated as disabled const aDisabled = aConfig?.disabled || !aConfig; const bDisabled = bConfig?.disabled || !bConfig; @@ -242,11 +272,15 @@ export function ModelSelector() { const hasAccess = hasAccessToModel(availableModel.id); const isRestricted = (requiresPro || requiresStarter) && !hasAccess; + // Disable non-vision models if chat has images + const isDisabledDueToImages = chatHasImages && !config?.supportsVision; + const effectivelyDisabled = isDisabled || isDisabledDueToImages; + return ( { - if (isDisabled) return; + if (effectivelyDisabled) return; if (isRestricted) { // Navigate to pricing page for upgrade navigate({ to: "/pricing" }); @@ -255,13 +289,13 @@ export function ModelSelector() { } }} className={`flex items-center justify-between group ${ - isDisabled ? "opacity-50 cursor-not-allowed" : "" + effectivelyDisabled ? "opacity-50 cursor-not-allowed" : "" } ${isRestricted ? "hover:bg-purple-50 dark:hover:bg-purple-950/20" : ""}`} - disabled={isDisabled} + disabled={effectivelyDisabled} >
{getDisplayName(availableModel.id, true)}
- {isRestricted && ( + {isRestricted && !isDisabledDueToImages && ( Upgrade? diff --git a/frontend/src/components/RenameChatDialog.tsx b/frontend/src/components/RenameChatDialog.tsx index 840c8087..ba09ea48 100644 --- a/frontend/src/components/RenameChatDialog.tsx +++ b/frontend/src/components/RenameChatDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -38,17 +38,14 @@ export function RenameChatDialog({ } }, [open, currentTitle]); - const resetForm = useCallback(() => { - setNewTitle(currentTitle); - setError(null); - setIsLoading(false); - }, [currentTitle]); - useEffect(() => { if (!open) { - resetForm(); + // Reset form state when dialog closes + setNewTitle(currentTitle); + setError(null); + setIsLoading(false); } - }, [open, resetForm]); + }, [open, currentTitle]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 65b6c56c..4ef481b7 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import { Button } from "./ui/button"; import { useLocation, useRouter } from "@tanstack/react-router"; import { ChatHistoryList } from "./ChatHistoryList"; import { AccountMenu } from "./AccountMenu"; -import { useRef, useEffect, KeyboardEvent } from "react"; +import { useRef, useEffect, KeyboardEvent, useCallback, useLayoutEffect } from "react"; import { cn, useClickOutside, useIsMobile } from "@/utils/utils"; import { Input } from "./ui/input"; import { useLocalState } from "@/state/useLocalState"; @@ -66,33 +66,54 @@ export function Sidebar({ const sidebarRef = useRef(null); // Modified click outside handler to ignore clicks in dropdowns and dialogs - useClickOutside(sidebarRef, (event: MouseEvent | TouchEvent) => { - if (isOpen) { - // Check if the click was inside a dropdown or dialog - const target = event.target as HTMLElement; - const isInDropdown = target.closest('[role="menu"]'); - const isInDialog = target.closest('[role="dialog"]'); - const isInAlertDialog = target.closest('[role="alertdialog"]'); - - if (!isInDropdown && !isInDialog && !isInAlertDialog) { - onToggle(); + const handleClickOutside = useCallback( + (event: MouseEvent | TouchEvent) => { + if (isOpen) { + // Check if the click was inside a dropdown or dialog + const target = event.target as HTMLElement; + const isInDropdown = target.closest('[role="menu"]'); + const isInDialog = target.closest('[role="dialog"]'); + const isInAlertDialog = target.closest('[role="alertdialog"]'); + + if (!isInDropdown && !isInDialog && !isInAlertDialog) { + onToggle(); + } } - } - }); + }, + [isOpen, onToggle] + ); + + useClickOutside(sidebarRef, handleClickOutside); // Use the centralized hook for mobile detection const isMobile = useIsMobile(); + // Track if component is mounted to prevent state updates after unmount + const isMountedRef = useRef(true); + useLayoutEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + // This effect closes the sidebar on mobile when navigating, // but preserves search state between navigations useEffect(() => { + // Only subscribe if we're on mobile and sidebar is open + if (!isMobile || !isOpen) return; + const unsubscribe = router.subscribe("onResolved", () => { - // On mobile: close the sidebar when navigating to any page - // On desktop: keep the sidebar open - if (isOpen && isMobile) { - // Always close sidebar on mobile when navigating to preserve screen real estate - onToggle(); - } + // Use a microtask to avoid state updates during render + queueMicrotask(() => { + // Prevent updates if component unmounted + if (!isMountedRef.current) return; + + // Double-check conditions after async boundary + if (isOpen && isMobile) { + onToggle(); + } + }); }); return () => { diff --git a/frontend/src/components/markdown.tsx b/frontend/src/components/markdown.tsx index 99ed5c93..abbf04e5 100644 --- a/frontend/src/components/markdown.tsx +++ b/frontend/src/components/markdown.tsx @@ -6,10 +6,12 @@ import RemarkBreaks from "remark-breaks"; import RehypeKatex from "rehype-katex"; import RemarkGfm from "remark-gfm"; import RehypeHighlight from "rehype-highlight"; +import RehypeSanitize from "rehype-sanitize"; import { useRef, useState, RefObject, useEffect, useMemo } from "react"; import React from "react"; import { Button } from "./ui/button"; -import { Check, Copy, ChevronDown, ChevronRight, Brain } from "lucide-react"; +import { Check, Copy, ChevronDown, ChevronRight, Brain, FileText } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; async function copyToClipboard(text: string) { try { @@ -136,7 +138,11 @@ function parseThinkingTags(content: string, isComplete: boolean = false): Parsed } // Pattern to match tags (complete or incomplete) - const thinkPattern = /([\s\S]*?)<\/think>|([\s\S]*?)$/g; + // During streaming (!isComplete), we want to catch as soon as it appears + const thinkPattern = isComplete + ? /([\s\S]*?)<\/think>|([\s\S]*?)$/g + : /([\s\S]*?)(?:<\/think>|$)/g; + let lastIndex = 0; let match; @@ -149,16 +155,24 @@ function parseThinkingTags(content: string, isComplete: boolean = false): Parsed } } - // Extract content from either complete or incomplete tag - const thinkContent = (match[1] || match[2] || "").trim(); + // Extract content from the match + const thinkContent = match[1] ?? match[2] ?? ""; - // Only add thinking block if it has actual content (not just whitespace) - if (thinkContent) { + // During streaming, even empty think tags should be shown to indicate thinking is starting + if (!isComplete && match[0].includes("")) { + parts.push({ + type: "thinking", + content: thinkContent, + duration: undefined, + id: `think-${match.index}` + }); + } else if (thinkContent.trim()) { + // For complete content, only add if there's actual content parts.push({ type: "thinking", content: thinkContent, - duration: undefined, // Let the UI calculate based on word count - id: `think-${match.index}` // Unique ID based on position + duration: undefined, + id: `think-${match.index}` }); } @@ -320,6 +334,7 @@ function MarkDownContentToMemo(props: { content: string }) { remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]} rehypePlugins={[ RehypeKatex, + RehypeSanitize, [ RehypeHighlight, { @@ -348,6 +363,215 @@ function MarkDownContentToMemo(props: { content: string }) { export const MarkdownContent = React.memo(MarkDownContentToMemo); +interface DocumentData { + document: { + filename: string; + md_content: string | null; + json_content: string | null; + html_content: string | null; + text_content: string | null; + doctags_content: string | null; + }; + status: string; + errors: unknown[]; + processing_time: number; + timings: Record; +} + +// Type guard to validate DocumentData structure +function isDocumentData(obj: unknown): obj is DocumentData { + if (!obj || typeof obj !== "object") return false; + + const data = obj as Record; + + // Check top-level properties + if ( + !("document" in data) || + !("status" in data) || + !("errors" in data) || + !("processing_time" in data) + ) { + return false; + } + + // Check document object structure + const doc = data.document; + if (!doc || typeof doc !== "object") return false; + + const docObj = doc as Record; + + // Check required document properties + if (!("filename" in docObj) || typeof docObj.filename !== "string") return false; + + // Check optional content properties (must be string or null) + const contentFields = [ + "md_content", + "json_content", + "html_content", + "text_content", + "doctags_content" + ]; + for (const field of contentFields) { + if (field in docObj && docObj[field] !== null && typeof docObj[field] !== "string") { + return false; + } + } + + // Basic type checks for other fields + if (typeof data.status !== "string") return false; + if (!Array.isArray(data.errors)) return false; + if (typeof data.processing_time !== "number") return false; + + return true; +} + +function DocumentPreview({ documentData }: { documentData: DocumentData }) { + const [isOpen, setIsOpen] = useState(false); + + // Extract content with fallbacks + const content = + documentData.document.md_content || + documentData.document.json_content || + documentData.document.html_content || + documentData.document.text_content || + documentData.document.doctags_content || + "No content available"; + + return ( + <> +
+ +
+ + + + + {documentData.document.filename} + +
+ +
+
+
+ + ); +} + +function parseDocumentJson(text: string): { data: DocumentData; endIndex: number } | null { + const start = text.indexOf('{"document":'); + if (start === -1) return null; + + // Try to find a complete JSON object using a more robust approach + // We'll attempt to parse progressively larger substrings + const jsonStart = text.substring(start); + + // First, try to parse the entire remaining string + try { + const parsed = JSON.parse(jsonStart); + // Validate the structure using our type guard + if (isDocumentData(parsed)) { + return { data: parsed, endIndex: start + jsonStart.length }; + } + } catch { + // If full parse fails, we need to find the end of the JSON object + } + + // Use a state machine approach to properly handle strings and escapes + let inString = false; + let escapeNext = false; + let depth = 0; + + for (let i = 0; i < jsonStart.length; i++) { + const ch = jsonStart[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (ch === "\\") { + escapeNext = true; + continue; + } + + if (ch === '"' && !escapeNext) { + inString = !inString; + continue; + } + + if (!inString) { + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { + try { + const candidate = jsonStart.substring(0, i + 1); + const parsed = JSON.parse(candidate); + // Validate the structure using our type guard + if (isDocumentData(parsed)) { + return { data: parsed, endIndex: start + i + 1 }; + } + } catch (e) { + // Continue searching if this wasn't valid JSON + console.error("Document JSON parse error at position", i, e); + } + } + } + } + } + + return null; // incomplete JSON – wait for more chunks +} + +function parseContentWithDocuments( + content: string +): Array<{ type: "text" | "document"; content: string | DocumentData }> { + const parts: Array<{ type: "text" | "document"; content: string | DocumentData }> = []; + + // Check if content contains document JSON + if (content.includes('{"document":')) { + const jsonStartIndex = content.indexOf('{"document":'); + const beforeJson = content.substring(0, jsonStartIndex).trim(); + + // Add any text before the JSON + if (beforeJson) { + parts.push({ type: "text", content: beforeJson }); + } + + // Try to parse the document JSON + const parseResult = parseDocumentJson(content); + if (parseResult) { + parts.push({ type: "document", content: parseResult.data }); + + // Find any text after the JSON using the endIndex from parsing + const afterJson = content.substring(parseResult.endIndex).trim(); + if (afterJson) { + parts.push({ type: "text", content: afterJson }); + } + } else { + // If parsing failed, just show as text + parts.push({ type: "text", content: content }); + } + } else { + // No document detected, treat as regular text + parts.push({ type: "text", content: content }); + } + + return parts; +} + function MarkdownWithThinking({ content, loading = false, @@ -366,9 +590,14 @@ function MarkdownWithThinking({ <> {parsedContent.map((part, index) => { if (part.type === "thinking") { - // Check if this is the last part and we're still loading (no closing tag) + // Check if this thinking block is still being streamed const isLastPart = index === parsedContent.length - 1; - const isThinking = loading && isLastPart && !content.includes("
"); + // During streaming, check if this thinking block doesn't have a closing tag + const thisThinkingPosition = content.lastIndexOf(""); + const closingPosition = content.lastIndexOf(""); + + // It's actively thinking if we're loading and this think tag hasn't been closed yet + const isThinking = loading && isLastPart && closingPosition < thisThinkingPosition; return ( ); } else { - return ; + // Parse content for documents + const contentParts = parseContentWithDocuments(part.content); + return ( + + {contentParts.map((contentPart, partIndex) => { + if (contentPart.type === "document") { + return ( + + ); + } else { + return ( + + ); + } + })} + + ); } })} diff --git a/frontend/src/config/pricingConfig.tsx b/frontend/src/config/pricingConfig.tsx index e1360b50..41d692c4 100644 --- a/frontend/src/config/pricingConfig.tsx +++ b/frontend/src/config/pricingConfig.tsx @@ -43,7 +43,9 @@ export const PRICING_PLANS: PricingPlan[] = [ }, { text: "Rename Chats", included: true, icon: }, { text: "Gemma 3 27B", included: false, icon: }, - { text: "DeepSeek R1 70B", included: false, icon: } + { text: "DeepSeek R1 70B", included: false, icon: }, + { text: "Image Upload", included: false, icon: }, + { text: "Document Upload", included: false, icon: } ], ctaText: "Start Free" }, @@ -68,7 +70,9 @@ export const PRICING_PLANS: PricingPlan[] = [ icon: }, { text: "Gemma 3 27B", included: true, icon: }, - { text: "DeepSeek R1 70B", included: false, icon: } + { text: "DeepSeek R1 70B", included: false, icon: }, + { text: "Image Upload", included: false, icon: }, + { text: "Document Upload", included: false, icon: } ], ctaText: "Start Chatting" }, @@ -98,8 +102,9 @@ export const PRICING_PLANS: PricingPlan[] = [ included: true, icon: }, + { text: "Image Upload", included: true, icon: }, { - text: "Upcoming Pro-only features", + text: "Document Upload", included: true, icon: } @@ -137,6 +142,12 @@ export const PRICING_PLANS: PricingPlan[] = [ text: "DeepSeek R1 70B", included: true, icon: + }, + { text: "Image Upload", included: true, icon: }, + { + text: "Document Upload", + included: true, + icon: } ], ctaText: "Contact Us" diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts new file mode 100644 index 00000000..a520ac60 --- /dev/null +++ b/frontend/src/hooks/useChatSession.ts @@ -0,0 +1,384 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Chat, ChatMessage, DEFAULT_MODEL_ID } from "@/state/LocalStateContext"; +import { ChatContentPart } from "@/state/LocalStateContextDef"; +import { fileToDataURL } from "@/utils/file"; +import { BillingStatus } from "@/billing/billingApi"; +import { MODEL_CONFIG } from "@/components/ModelSelector"; + +type ChatPhase = "idle" | "streaming" | "persisting"; + +interface UseChatSessionOptions { + getChatById: (chatId: string) => Promise; + persistChat: (chat: Chat) => Promise; + openai: ReturnType; + model: string; +} + +export function useChatSession( + chatId: string, + options: UseChatSessionOptions & { + onImageConversionError?: (failedCount: number) => void; + } +) { + const { getChatById, persistChat, openai, model, onImageConversionError } = options; + const queryClient = useQueryClient(); + const [phase, setPhase] = useState("idle"); + const [optimisticChat, setOptimisticChat] = useState(null); + const [currentStreamingMessage, setCurrentStreamingMessage] = useState(); + const processingRef = useRef(false); + const abortControllerRef = useRef(null); + + // Query the chat from backend + const { data: serverChat, isPending } = useQuery({ + queryKey: ["chat", chatId], + queryFn: () => getChatById(chatId), + retry: false + }); + + // Reset optimistic chat when chatId changes + useEffect(() => { + // Abort any ongoing streaming when chatId changes + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + + setOptimisticChat(null); + setPhase("idle"); + setCurrentStreamingMessage(undefined); + processingRef.current = false; + }, [chatId]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + // Part A: Apply guard when syncing server data to optimistic state + useEffect(() => { + if (!serverChat || isPending) return; + + setOptimisticChat((prev) => { + if (!prev) return serverChat; // first load + // Never downgrade - if server has fewer messages, keep local state + if (serverChat.messages.length <= prev.messages.length) return prev; + return serverChat; + }); + }, [serverChat, isPending]); + + // Mutation for persisting chat + const persistMutation = useMutation({ + mutationFn: persistChat, + onSuccess: () => { + setPhase("idle"); + queryClient.invalidateQueries({ queryKey: ["chat", chatId] }); + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + queryClient.invalidateQueries({ queryKey: ["billingStatus"] }); + }, + onError: () => { + setPhase("idle"); + } + }); + + // Current chat is optimistic if we have it, otherwise server data + const chat: Chat = useMemo( + () => + optimisticChat || + serverChat || { + id: chatId, + title: "New Chat", + messages: [] + }, + [optimisticChat, serverChat, chatId] + ); + + const streamAssistant = useCallback( + async (messages: ChatMessage[]): Promise => { + // Create new abort controller for this stream + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + const stream = openai.beta.chat.completions.stream({ + model, + messages: messages as Parameters< + typeof openai.beta.chat.completions.stream + >[0]["messages"], + stream: true + }); + + let fullResponse = ""; + setCurrentStreamingMessage(""); + + for await (const chunk of stream) { + // Check if we should abort + if (abortController.signal.aborted) { + stream.controller.abort(); + throw new Error("Stream aborted"); + } + + const content = chunk.choices[0]?.delta?.content || ""; + fullResponse += content; + setCurrentStreamingMessage(fullResponse); + } + + await stream.finalChatCompletion(); + setCurrentStreamingMessage(undefined); + return fullResponse; + } catch (error) { + if (abortController.signal.aborted) { + throw new Error("Stream aborted"); + } + throw error; + } finally { + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } + } + }, + [openai, model] + ); + + const appendUserMessage = useCallback( + async ( + content: string, + images?: File[], + documentText?: string, + documentMetadata?: { filename: string; fullContent: string } + ) => { + if (phase !== "idle") { + return; + } + + if (processingRef.current) { + return; + } + + // Handle images for vision-capable models + const modelSupportsVision = MODEL_CONFIG[model]?.supportsVision || false; + let userMessage: ChatMessage; + + // If document text is provided, combine it with the content + let finalContent = content; + if (documentText) { + finalContent = documentText + (content ? `\n\n${content}` : ""); + } + + if (modelSupportsVision && images && images.length > 0) { + const parts: ChatContentPart[] = [{ type: "text", text: finalContent }]; + let failedImageCount = 0; + + for (const file of images) { + try { + const url = await fileToDataURL(file); + parts.push({ type: "image_url", image_url: { url } }); + } catch (error) { + console.error("[useChatSession] Failed to convert image to data URL:", error); + failedImageCount++; + continue; + } + } + + // Notify about failed conversions + if (failedImageCount > 0 && onImageConversionError) { + onImageConversionError(failedImageCount); + } + + // If we have at least text content (and potentially some images), create multimodal message + // If no images were successfully processed, the message will just have text + userMessage = { role: "user", content: parts }; + } else { + userMessage = { role: "user", content: finalContent }; + } + + // Add document metadata if provided + if (documentMetadata) { + userMessage.document = documentMetadata; + } + + // Check again after async operations to prevent double execution + if (processingRef.current) { + return; + } + + processingRef.current = true; + setPhase("streaming"); + + const newMessages = [...chat.messages, userMessage]; + + // Update optimistic state immediately + setOptimisticChat({ + ...chat, + messages: newMessages + }); + + try { + // Start title generation in background if needed + let titlePromise: Promise | undefined; + if (chat.title === "New Chat") { + titlePromise = generateTitle(newMessages, openai, queryClient); + // Update title in UI as soon as it's ready + titlePromise.then((generatedTitle) => { + setOptimisticChat((prev) => { + if (!prev) return prev; + return { ...prev, title: generatedTitle }; + }); + }); + } + + // Stream assistant response + const assistantResponse = await streamAssistant(newMessages); + + // Add assistant message + const finalMessages = [ + ...newMessages, + { role: "assistant", content: assistantResponse } as ChatMessage + ]; + + // Update optimistic state with assistant message + setOptimisticChat((prev) => ({ + ...prev!, + messages: finalMessages + })); + + // Wait for title generation if it was started + const title = titlePromise ? await titlePromise : chat.title; + + // Persist to backend + setPhase("persisting"); + await persistMutation.mutateAsync({ + id: chatId, + title, + model, + messages: finalMessages + }); + } catch (error) { + setPhase("idle"); + processingRef.current = false; + + // Don't throw if it was an intentional abort + if (error instanceof Error && error.message === "Stream aborted") { + return; + } + + throw error; + } finally { + processingRef.current = false; + } + }, + [chat, model, phase, streamAssistant, persistMutation, chatId, openai, queryClient] + ); + + return { + chat, + phase, + currentStreamingMessage, + appendUserMessage, + streamAssistant + }; +} + +// Helper to generate chat title +async function generateTitle( + messages: ChatMessage[], + openai: ReturnType, + queryClient: ReturnType +): Promise { + // Helper function to check if the user is on a free plan + function isUserOnFreePlan(): boolean { + try { + const billingStatus = queryClient.getQueryData(["billingStatus"]) as + | BillingStatus + | undefined; + + return ( + !billingStatus || + !billingStatus.product_name || + billingStatus.product_name.toLowerCase().includes("free") + ); + } catch (error) { + console.log("Error checking billing status, defaulting to free plan", error); + return true; // Default to free plan if there's an error + } + } + + const userMessage = messages.find((m) => m.role === "user"); + if (!userMessage) return "New Chat"; + + let messageText = "New Chat"; + + if (typeof userMessage.content === "string") { + messageText = userMessage.content; + } else if (Array.isArray(userMessage.content)) { + // Find the first text part safely + const textPart = userMessage.content.find( + (part): part is { type: "text"; text: string } => + part.type === "text" && "text" in part && typeof part.text === "string" + ); + if (textPart) { + messageText = textPart.text; + } + } + + // Simple title generation - truncate first message to 50 chars + const simpleTitleFromMessage = messageText.slice(0, 50).trim(); + + // For free plan users, just use the simple title + // For paid plans, try to generate AI title + if (isUserOnFreePlan()) { + console.log("Using simple title generation for free plan user"); + return simpleTitleFromMessage; + } + + // For paid plans, use LLM to generate a smart title + try { + console.log("Using AI title generation for paid plan user"); + // Get the user's first message, truncate if too long + const userContent = messageText.slice(0, 500); // Reduced to 500 chars to optimize token usage + + // Use the OpenAI API to generate a concise title - use the default model + const stream = openai.beta.chat.completions.stream({ + model: DEFAULT_MODEL_ID, // Use the default model instead of user selected model + messages: [ + { + role: "system", + content: + "You are a helpful assistant that generates concise, meaningful titles (3-5 words) for chat conversations based on the user's first message. Return only the title without quotes or explanations." + }, + { + role: "user", + content: `Generate a concise, contextual title (3-5 words) for a chat that starts with this message: "${userContent}"` + } + ], + temperature: 0.7, + max_tokens: 15, // Keep response very short + stream: true + }); + + let generatedTitle = ""; + for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content || ""; + generatedTitle += content; + } + + // Get the final completion + await stream.finalChatCompletion(); + + // Remove quotes if present and limit length + const cleanTitle = generatedTitle + .replace(/^["']|["']$/g, "") // Remove leading/trailing quotes + .trim() + .slice(0, 50); + + console.log("Generated title:", cleanTitle); + return cleanTitle || simpleTitleFromMessage; // Fallback if generation fails + } catch (error) { + console.error("Error generating AI title, falling back to simple title:", error); + return simpleTitleFromMessage; + } +} diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index 606b6b0c..793a0ae5 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -5,14 +5,13 @@ import ChatBox from "@/components/ChatBox"; import { useOpenAI } from "@/ai/useOpenAi"; import { useLocalState } from "@/state/useLocalState"; import { Markdown, stripThinkingTags } from "@/components/markdown"; -import { ChatMessage, Chat, DEFAULT_MODEL_ID } from "@/state/LocalStateContext"; -import { AlertDestructive } from "@/components/AlertDestructive"; +import { ChatMessage, DEFAULT_MODEL_ID } from "@/state/LocalStateContext"; import { Sidebar, SidebarToggle } from "@/components/Sidebar"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; -import { BillingStatus } from "@/billing/billingApi"; import { useNavigate, useLocation } from "@tanstack/react-router"; import { useIsMobile } from "@/utils/utils"; +import { useChatSession } from "@/hooks/useChatSession"; export const Route = createFileRoute("/_auth/chat/$chatId")({ component: ChatComponent @@ -35,16 +34,27 @@ function useCopyToClipboard(text: string) { return { isCopied, handleCopy }; } -function UserMessage({ text, chatId }: { text: string; chatId: string }) { +function renderContent(content: ChatMessage["content"], chatId: string) { + if (typeof content === "string") { + return ; + } + return content.map((p, idx) => + p.type === "text" ? ( + + ) : ( + + ) + ); +} + +function UserMessage({ message, chatId }: { message: ChatMessage; chatId: string }) { return (
-
- -
+
{renderContent(message.content, chatId)}
); @@ -158,6 +168,8 @@ function ChatComponent() { setUserPrompt, systemPrompt, setSystemPrompt, + userImages, + setUserImages, addChat } = useLocalState(); const openai = useOpenAI(); @@ -167,11 +179,85 @@ function ChatComponent() { const location = useLocation(); const isMobile = useIsMobile(); - const [error, setError] = useState(""); const [isSummarizing, setIsSummarizing] = useState(false); + const [imageConversionError, setImageConversionError] = useState(null); const chatContainerRef = useRef(null); + // Use the chat session hook + const { + chat: localChat, + phase, + currentStreamingMessage, + appendUserMessage + } = useChatSession(chatId, { + getChatById, + persistChat, + openai, + model, + onImageConversionError: (failedCount) => { + setImageConversionError(`${failedCount} image(s) failed to process. Please try again.`); + // Clear error after 5 seconds + setTimeout(() => setImageConversionError(null), 5000); + } + }); + + // Handle initial user prompt - using a ref to prevent double execution + const initialPromptProcessedRef = useRef(false); + + // Reset the ref when chatId changes + useEffect(() => { + initialPromptProcessedRef.current = false; + }, [chatId]); + + useEffect(() => { + // Check if we have a prompt to process and haven't processed it yet + if ( + userPrompt && + localChat.messages.length === 0 && + phase === "idle" && + !initialPromptProcessedRef.current + ) { + // Mark as processed immediately + initialPromptProcessedRef.current = true; + + // Capture values before clearing + const prompt = userPrompt; + const sysPrompt = systemPrompt; + const images = userImages; + + // Clear state immediately + setUserPrompt(""); + setSystemPrompt(null); + setUserImages([]); + + // Combine prompts + const finalPrompt = sysPrompt ? `[System: ${sysPrompt}]\n\n${prompt}` : prompt; + + // Send message + appendUserMessage(finalPrompt, images).catch((error) => { + // Only reset if it wasn't an abort + if (!(error instanceof Error) || error.message !== "Stream aborted") { + console.error("[ChatComponent] Failed to append message:", error); + setUserPrompt(prompt); + setSystemPrompt(sysPrompt); + setUserImages(images); + initialPromptProcessedRef.current = false; + } + }); + } + }, [ + userPrompt, + systemPrompt, + userImages, + localChat.messages.length, + phase, + appendUserMessage, + setUserPrompt, + setSystemPrompt, + setUserImages + ]); + // Handle mobile new chat (matching sidebar behavior) const handleMobileNewChat = useCallback(async () => { // If we're already on "/", focus the chat box @@ -217,365 +303,79 @@ function ChatComponent() { } }, []); - // Query the chat from the backend, in case it already exists - const { - isPending, - error: queryError, - data: queryChat - } = useQuery({ - queryKey: ["chat", chatId], - queryFn: () => { - return getChatById(chatId); - }, - retry: false - }); - - useEffect(() => { - if (queryError) { - console.error("Error fetching chat:", queryError); - setError("Error fetching chat. Please try again."); - } - }, [queryError]); - - // We need to keep a local state so we can stream in chat responses - const [localChat, setLocalChat] = useState({ - id: chatId, - title: "New Chat", - messages: [] - }); - - const [currentStreamingMessage, setCurrentStreamingMessage] = useState(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); const toggleSidebar = useCallback(() => setIsSidebarOpen((prev) => !prev), []); - // Track if we've already set the model for this chat - const modelSetForChatRef = useRef(null); - + // Set model when chat first loads + const hasSetModelRef = useRef(false); useEffect(() => { - if (queryChat && !isPending) { - console.debug("Chat loaded from query:", queryChat); - if (queryChat.id !== chatId) { - console.error("Chat ID mismatch"); - setLocalChat((localChat) => ({ ...localChat, messages: [] })); - return; - } - if (queryChat.messages.length === 0) { - console.warn("Chat has no messages, using user prompt"); - - // Build messages array with system prompt first (if exists), then user prompt - const messages: ChatMessage[] = []; - - // Check for system prompt from LocalState - if (systemPrompt?.trim()) { - messages.push({ role: "system", content: systemPrompt.trim() } as ChatMessage); - } - - // Add user prompt if exists - if (userPrompt) { - messages.push({ role: "user", content: userPrompt } as ChatMessage); - } - - setLocalChat((localChat) => ({ ...localChat, messages })); - return; - } - setLocalChat(queryChat); + if (localChat.model && !hasSetModelRef.current) { + setModel(localChat.model); + hasSetModelRef.current = true; } - // I don't want to re-run this effect if the user prompt or system prompt changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [queryChat, chatId, isPending]); + }, [localChat.model, setModel]); + // Reset the ref when chatId changes useEffect(() => { - if (queryChat && !isPending) { - if (modelSetForChatRef.current !== chatId) { - const chatModel = queryChat.model || DEFAULT_MODEL_ID; - /** ① Set global selector ② also store on local chat state */ - setModel(chatModel); - setLocalChat((prev) => ({ ...prev, model: chatModel })); - modelSetForChatRef.current = chatId; - } - } - }, [queryChat, chatId, isPending, setModel]); + hasSetModelRef.current = false; + }, [chatId]); - // IMPORTANT that this runs only once (because it uses the user's tokens!) - const userPromptEffectRan = useRef(false); + // Removed auto-persist on model change to prevent unwanted saves + // The model will be saved with the chat when messages are sent - useEffect(() => { - // Make sure we don't run this more than once per mount - if (userPromptEffectRan.current) return; - userPromptEffectRan.current = true; - - // Check if we have a user prompt to send - if (userPrompt) { - console.log("User prompt found for chatId:", chatId, "sending to chat"); - console.log("USER PROMPT:", userPrompt); - - // Set a small delay to ensure all state is properly initialized - setTimeout(() => { - sendMessage(userPrompt, systemPrompt || undefined); - }, 100); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [isLoading, setIsLoading] = useState(false); - - const sendMessage = useCallback( - async (input: string, systemPrompt?: string) => { - // Helper function to check if the user is on a free plan - function isUserOnFreePlan(): boolean { - try { - const billingStatus = queryClient.getQueryData(["billingStatus"]) as - | BillingStatus - | undefined; - - return ( - !billingStatus || - !billingStatus.product_name || - billingStatus.product_name.toLowerCase().includes("free") - ); - } catch (error) { - console.log("Error checking billing status, defaulting to free plan", error); - return true; // Default to free plan if there's an error - } - } - - async function generateChatTitle(messages: ChatMessage[]): Promise { - // Find the first user message - const userMessage = messages.find((message) => message.role === "user"); - if (!userMessage) return "New Chat"; - - // Simple title generation - truncate first message to 50 chars - const simpleTitleFromMessage = userMessage.content.slice(0, 50).trim(); + const isLoading = phase === "streaming"; + const isPersisting = phase === "persisting"; - // For free plan users, just use the simple title - // For paid plans, try to generate AI title - if (isUserOnFreePlan()) { - console.log("Using simple title generation for free plan user"); - return simpleTitleFromMessage; - } + // Auto-scroll when new messages appear (user message or start of streaming) + const prevMessageCountRef = useRef(localChat.messages.length); + const prevStreamingRef = useRef(false); - // For paid plans, use LLM to generate a smart title - try { - console.log("Using AI title generation for paid plan user"); - // Get the user's first message, truncate if too long - const userContent = userMessage.content.slice(0, 500); // Reduced to 500 chars to optimize token usage - - // Use the OpenAI API to generate a concise title - use the default model - const stream = openai.beta.chat.completions.stream({ - model: DEFAULT_MODEL_ID, // Use the default model instead of user selected model - messages: [ - { - role: "system", - content: - "You are a helpful assistant that generates concise, meaningful titles (3-5 words) for chat conversations based on the user's first message. Return only the title without quotes or explanations." - }, - { - role: "user", - content: `Generate a concise, contextual title (3-5 words) for a chat that starts with this message: "${userContent}"` - } - ], - temperature: 0.7, - max_tokens: 15, // Keep response very short - stream: true + useEffect(() => { + const messageCount = localChat.messages.length; + const hasNewMessage = messageCount > prevMessageCountRef.current; + const justStartedStreaming = isLoading && !prevStreamingRef.current; + + if (hasNewMessage || justStartedStreaming) { + // Always scroll for new user messages or when streaming starts + const container = chatContainerRef.current; + if (container) { + requestAnimationFrame(() => { + container.scrollTo({ + top: container.scrollHeight, + behavior: "smooth" }); - - let generatedTitle = ""; - for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content || ""; - generatedTitle += content; - } - - // Get the final completion - await stream.finalChatCompletion(); - - // Remove quotes if present and limit length - const cleanTitle = generatedTitle - .replace(/^["']|["']$/g, "") // Remove surrounding quotes if present - .replace(/\n/g, " ") // Remove new lines - .trim(); - - return cleanTitle || simpleTitleFromMessage; // Fallback to simple title if generation fails - } catch (error) { - console.error("Failed to generate chat title:", error); - // Fallback to simple title method - return simpleTitleFromMessage; - } - } - if (!input.trim() || !localChat) return; - setError(""); - - // Build new messages array with system prompt if this is the first message - let newMessages: ChatMessage[]; - - if (localChat.messages.length === 0 && systemPrompt?.trim()) { - // First message: add system prompt, then user message - newMessages = [ - { role: "system", content: systemPrompt.trim() } as ChatMessage, - { role: "user", content: input } as ChatMessage - ]; - } else { - // Subsequent messages: just add user message - newMessages = [...localChat.messages, { role: "user", content: input } as ChatMessage]; + }); } + } - setLocalChat((prev) => ({ - ...prev, - messages: newMessages - })); + prevMessageCountRef.current = messageCount; + prevStreamingRef.current = isLoading; + }, [localChat.messages.length, isLoading]); - // Scroll to bottom when user sends message + const sendMessage = useCallback( + async ( + input: string, + systemPrompt?: string, + images?: File[], + documentText?: string, + documentMetadata?: { filename: string; fullContent: string } + ) => { + // Handle system prompt if provided + const messageContent = systemPrompt ? `[System: ${systemPrompt}]\n\n${input}` : input; + + // Use the appendUserMessage from the hook + await appendUserMessage(messageContent, images, documentText, documentMetadata); + + // Scroll to bottom after sending requestAnimationFrame(() => { chatContainerRef.current?.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: "smooth" }); }); - - setIsLoading(true); - - try { - // Start title generation early for paid users if needed - let titleGenerationPromise; - let title = localChat.title; - - if (title === "New Chat") { - const isFreePlan = isUserOnFreePlan(); - - if (!isFreePlan) { - console.log("Starting async AI title generation for paid user's chat"); - // Start title generation in parallel for paid users - titleGenerationPromise = generateChatTitle(newMessages).then((newTitle) => { - // Clean up the title - const cleanTitle = newTitle.replace(/"/g, "").replace(/\n/g, " "); - - // Update local chat with generated title immediately when available - setLocalChat((prev) => ({ - ...prev, - title: cleanTitle - })); - - return cleanTitle; - }); - } else { - console.log("Using simple title for free user's chat"); - // For free users, set the title synchronously - const newTitle = await generateChatTitle(newMessages); - title = newTitle.replace(/"/g, "").replace(/\n/g, " "); - - setLocalChat((prev) => ({ - ...prev, - title - })); - } - } - - // Stream the chat response (happens in parallel with title generation) - // newMessages already contains system prompt if it was the first message - - const stream = openai.beta.chat.completions.stream({ - model, - messages: newMessages, - stream: true - }); - - let fullResponse = ""; - let isFirstChunk = true; - - for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content || ""; - fullResponse += content; - setCurrentStreamingMessage(fullResponse); - - // Scroll to bottom on first chunk of the response - if (isFirstChunk && content.trim()) { - requestAnimationFrame(() => { - chatContainerRef.current?.scrollTo({ - top: chatContainerRef.current.scrollHeight, - behavior: "smooth" - }); - }); - isFirstChunk = false; - } - } - - // Save scroll position before state updates - const container = chatContainerRef.current; - const scrollPosition = container?.scrollTop; - - const finalMessages = [ - ...newMessages, - { role: "assistant", content: fullResponse } as ChatMessage - ]; - setLocalChat((prev) => ({ - ...prev, - messages: finalMessages - })); - setCurrentStreamingMessage(undefined); - - // Restore scroll position after state updates - if (container && scrollPosition !== undefined) { - // Use requestAnimationFrame to ensure this runs after the render - requestAnimationFrame(() => { - // Ensure we don't scroll beyond bounds - const maxScroll = container.scrollHeight - container.clientHeight; - const boundedPosition = Math.min(scrollPosition, maxScroll); - container.scrollTop = boundedPosition; - }); - } - - // Wait for title generation to complete if we started it - if (titleGenerationPromise) { - title = await titleGenerationPromise; - } - - const chatCompletion = await stream.finalChatCompletion(); - console.log(chatCompletion); - - // Should be safe to clear these by now - setUserPrompt(""); - setSystemPrompt(null); - - // React sucks and doesn't get the latest state - // Use current title from localChat which may have been updated asynchronously - const currentTitle = localChat.title === "New Chat" ? title : localChat.title; - await persistChat({ ...localChat, model, title: currentTitle, messages: finalMessages }); - - // Invalidate chat history to show the new title in the sidebar - queryClient.invalidateQueries({ - queryKey: ["chatHistory"], - refetchType: "all" - }); - - // Invalidate current chat query to ensure the title update is reflected - queryClient.invalidateQueries({ - queryKey: ["chat", chatId], - refetchType: "all" - }); - - // Only invalidate billing status after everything is complete - queryClient.invalidateQueries({ - queryKey: ["billingStatus"], - refetchType: "all" - }); - } catch (error) { - // If there's an error, we should still refetch the billing status - // to make sure our optimistic update was correct - queryClient.invalidateQueries({ - queryKey: ["billingStatus"], - refetchType: "all" - }); - console.error("Error:", error); - if (error instanceof Error) setError(error.message); - } - - setIsLoading(false); }, - // We intentionally don't include freshBillingStatus in the dependency array - // even though it's used in the closure to avoid re-creating the function - // on every billing status change - [localChat, model, openai, persistChat, queryClient, setUserPrompt, setSystemPrompt, chatId] + [appendUserMessage] ); // Chat compression function @@ -608,7 +408,18 @@ END OF INSTRUCTIONS`; const summarizationMessages = [ { role: "system" as const, content: summarizerSystem }, - ...localChat.messages + ...localChat.messages.map((msg) => { + // Convert content to string for summarization + const content = + typeof msg.content === "string" + ? msg.content + : msg.content.map((part) => (part.type === "text" ? part.text : "[image]")).join(" "); + + return { + role: msg.role, + content: content + }; + }) ]; // 2. Stream the summary @@ -643,7 +454,7 @@ END OF INSTRUCTIONS`; // 3. Take the direct storage approach instead of relying on React state/effects // Create a fake user message directly in storage that the next page will read - const initialChatData: Chat = { + const initialChatData = { id: id, title: inheritedTitle, messages: [{ role: "user" as const, content: initialMsg }] @@ -664,19 +475,16 @@ END OF INSTRUCTIONS`; refetchType: "all" }); - // 5. Reset the flag for good measure - userPromptEffectRan.current = false; - - // 6. Navigate to the new chat which should now have the initial message + // 5. Navigate to the new chat which should now have the initial message console.log("Navigating to new chat with pre-persisted message:", id); navigate({ to: "/chat/$chatId", params: { chatId: id } }); } catch (e) { console.error("compressChat failed:", e); - setError("Could not compress chat – please try again."); + // Note: We don't have a setError function anymore since errors are handled by the hook } finally { setIsSummarizing(false); } - }, [localChat, model, openai, addChat, navigate, setUserPrompt]); + }, [localChat, openai, addChat, navigate, setUserPrompt, persistChat, queryClient]); return (
@@ -716,10 +524,25 @@ END OF INSTRUCTIONS`; id={`message-${message.role}-${index}`} className="flex flex-col gap-2" > - {message.role === "system" && } - {message.role === "user" && } + {message.role === "system" && ( + p.type === "text")?.text || "" + } + /> + )} + {message.role === "user" && } {message.role === "assistant" && ( - + p.type === "text")?.text || "" + } + chatId={chatId} + /> )}
))} @@ -746,13 +569,14 @@ END OF INSTRUCTIONS`; {/* Place the chat box inline (below messages) in normal flow */}
- {error && } + {/* Error handling can be added here if needed */}
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index bcd23d48..e8104896 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -64,10 +64,44 @@ function Index() { const toggleSidebar = useCallback(() => setIsSidebarOpen((prev) => !prev), []); - async function handleSubmit(input: string, systemPrompt?: string) { - if (input.trim() === "") return; - localState.setUserPrompt(input.trim()); + async function handleSubmit( + input: string, + systemPrompt?: string, + images?: File[], + documentText?: string, + documentMetadata?: { filename: string; fullContent: string } + ) { + // Allow submission if there's text input, images, or a document + const hasTextInput = input.trim() !== ""; + const hasImages = images && images.length > 0; + const hasDocument = documentText && documentText.trim() !== ""; + + if (!hasTextInput && !hasImages && !hasDocument) { + return; // Nothing to submit + } + + // Build the final input - handle case where there might be no text input + let finalInput = input.trim(); + + if (documentText && finalInput) { + // If there's both document and text input, combine them + finalInput = `${documentText}\n\n${finalInput}`; + } else if (documentText && !finalInput) { + // If only document, just use the document text + finalInput = documentText; + } + // If only images with no text, finalInput will be empty string which is fine + + localState.setUserPrompt(finalInput); localState.setSystemPrompt(systemPrompt?.trim() || null); + localState.setUserImages(images || []); + + // Store document metadata if provided (we'll need to add this to LocalState) + if (documentMetadata) { + // For now, we'll include it in the prompt until we add proper document storage + // TODO: Add document metadata to LocalState + } + const id = await localState.addChat(); navigate({ to: "/chat/$chatId", params: { chatId: id } }); } diff --git a/frontend/src/state/LocalStateContext.tsx b/frontend/src/state/LocalStateContext.tsx index 8ecf2bea..883bf547 100644 --- a/frontend/src/state/LocalStateContext.tsx +++ b/frontend/src/state/LocalStateContext.tsx @@ -26,6 +26,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) const [localState, setLocalState] = useState({ userPrompt: "", systemPrompt: null as string | null, + userImages: [] as File[], model: import.meta.env.VITE_DEV_MODEL_OVERRIDE || DEFAULT_MODEL_ID, availableModels: [llamaModel] as OpenSecretModel[], billingStatus: null as BillingStatus | null, @@ -91,6 +92,10 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) setLocalState((prev) => ({ ...prev, systemPrompt: prompt })); } + function setUserImages(images: File[]) { + setLocalState((prev) => ({ ...prev, userImages: images })); + } + function setBillingStatus(status: BillingStatus) { setLocalState((prev) => ({ ...prev, billingStatus: status })); } @@ -114,14 +119,14 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) return newChat.id; } - async function getChatById(id: string) { + async function getChatById(id: string): Promise { try { const chat = await get(`chat_${id}`); - if (!chat) throw new Error("Chat not found"); + if (!chat) return undefined; return JSON.parse(chat) as Chat; } catch (error) { console.error("Error fetching chat:", error); - throw new Error("Error fetching chat."); + return undefined; } } @@ -200,8 +205,12 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) async function renameChat(chatId: string, newTitle: string) { try { - // Get the current chat (getChatById already throws if chat not found) + // Get the current chat const chat = await getChatById(chatId); + if (!chat) { + console.error("Chat not found for renaming:", chatId); + throw new Error("Chat not found"); + } // Update the chat title chat.title = newTitle; @@ -244,7 +253,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) } function setModel(model: string) { - setLocalState((prev) => ({ ...prev, model })); + setLocalState((prev) => (prev.model === model ? prev : { ...prev, model })); } function setAvailableModels(models: OpenSecretModel[]) { @@ -260,6 +269,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) setAvailableModels, userPrompt: localState.userPrompt, systemPrompt: localState.systemPrompt, + userImages: localState.userImages, billingStatus: localState.billingStatus, searchQuery: localState.searchQuery, setSearchQuery, @@ -268,6 +278,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) setBillingStatus, setUserPrompt, setSystemPrompt, + setUserImages, addChat, getChatById, persistChat, diff --git a/frontend/src/state/LocalStateContextDef.ts b/frontend/src/state/LocalStateContextDef.ts index 93dc62cc..19abef5b 100644 --- a/frontend/src/state/LocalStateContextDef.ts +++ b/frontend/src/state/LocalStateContextDef.ts @@ -7,9 +7,19 @@ export interface OpenSecretModel extends Model { tasks?: string[]; } +export type ChatContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } }; + export type ChatMessage = { role: "user" | "assistant" | "system"; - content: string; + /** plain text for normal models, or multimodal array for multimodal models */ + content: string | ChatContentPart[]; + /** Optional document attachment for user messages */ + document?: { + filename: string; + fullContent: string; + }; }; export type Chat = { @@ -33,6 +43,7 @@ export type LocalState = { setAvailableModels: (models: OpenSecretModel[]) => void; userPrompt: string; systemPrompt: string | null; + userImages: File[]; billingStatus: BillingStatus | null; /** Current search query for filtering chat history */ searchQuery: string; @@ -45,6 +56,7 @@ export type LocalState = { setBillingStatus: (status: BillingStatus) => void; setUserPrompt: (prompt: string) => void; setSystemPrompt: (prompt: string | null) => void; + setUserImages: (images: File[]) => void; addChat: (title?: string) => Promise; getChatById: (id: string) => Promise; persistChat: (chat: Chat) => Promise; @@ -67,6 +79,7 @@ export const LocalStateContext = createContext({ setAvailableModels: () => void 0, userPrompt: "", systemPrompt: null, + userImages: [], billingStatus: null, searchQuery: "", setSearchQuery: () => void 0, @@ -75,6 +88,7 @@ export const LocalStateContext = createContext({ setBillingStatus: () => void 0, setUserPrompt: () => void 0, setSystemPrompt: () => void 0, + setUserImages: () => void 0, addChat: async () => "", getChatById: async () => undefined, persistChat: async () => void 0, diff --git a/frontend/src/utils/file.ts b/frontend/src/utils/file.ts new file mode 100644 index 00000000..00e1d1ff --- /dev/null +++ b/frontend/src/utils/file.ts @@ -0,0 +1,61 @@ +export function fileToDataURL(file: File): Promise { + // Check if FileReader exists (it doesn't on iOS WebView) + if (typeof FileReader === "undefined") { + // Use canvas to convert blob to data URL + return new Promise((resolve, reject) => { + const blobUrl = URL.createObjectURL(file); + const img = new Image(); + + img.onload = () => { + try { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("Failed to get canvas context"); + } + + // Set canvas size to image size + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + + // Draw image to canvas + ctx.drawImage(img, 0, 0); + + // Convert to data URL + const dataUrl = canvas.toDataURL(file.type || "image/png"); + + // Clean up + URL.revokeObjectURL(blobUrl); + + resolve(dataUrl); + } catch (error) { + URL.revokeObjectURL(blobUrl); + reject(error); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(blobUrl); + reject(new Error("Failed to load image")); + }; + + img.src = blobUrl; + }); + } + + // Standard FileReader approach + return new Promise((res, rej) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + res(reader.result); + } else { + rej(new Error("Unexpected FileReader result type")); + } + }; + reader.onerror = () => rej(reader.error); + reader.onabort = () => rej(new Error("FileReader operation was aborted")); + reader.readAsDataURL(file); + }); +} diff --git a/setup-hooks.sh b/setup-hooks.sh index 6c3ee745..2d02abb0 100755 --- a/setup-hooks.sh +++ b/setup-hooks.sh @@ -14,4 +14,6 @@ fi git config core.hooksPath .githooks echo "✅ Git hooks configured successfully!" -echo "The pre-commit hook will now run 'bun run build' before each commit." \ No newline at end of file +echo "The pre-commit hook will now:" +echo " 1. Check code formatting with 'bun run format:check'" +echo " 2. Run 'bun run build' to ensure the project builds" \ No newline at end of file