diff --git a/README.md b/README.md index 2f6d04ab..0479bc9b 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,11 @@ across Android, iOS, and macOS platforms. ![](assets/promo.avif) ### What's New 🔥 +- 🚀 Support Web Search for real-time information retrieval (From v2.7.0). +- 🚀 Support Create App to generate and preview mini web applications (From v2.7.0). +- 🚀 Support Image Gallery for browsing and managing generated images (From v2.7.0). - 🚀 Support streaming rendering of Mermaid charts (From v2.6.0). -
- - -
- 🚀 Support using Bedrock API Key for Amazon Bedrock models (From v2.5.0). -- 🚀 Support virtual try-on, automatically recognize clothes, pants, shoes and try them on (From v2.5.0). -- 🚀 Support shortcuts for macOS (From v2.5.0). - - Use `Shift + Enter`, `Control + Enter` or `Option + Enter` to add a line break. - - Use `⌘ + V` to add images (Screenshot), videos, or documents from your clipboard. - - Use `⌘ + N` to opening multiple Mac windows for parallel operations. ## 📱 Quick Download @@ -36,11 +30,6 @@ across Android, iOS, and macOS platforms. ## Getting Started with Amazon Bedrock -### Prerequisites - -Click [Amazon Bedrock Model access](https://console.aws.amazon.com/bedrock/home#/modelaccess) to enable your models -access. - ### Configuration You can choose one of the following two methods for configuration @@ -64,14 +53,9 @@ You can choose one of the following two methods for configuration ### Architecture -![](/assets/architecture.avif) +![](/assets/architecture.png) -By default, we use **AWS App Runner**, which is commonly used to host Python FastAPI servers, offering high performance, -scalability and low latency. - -Alternatively, we provide the option to replace App Runner with **AWS Lambda** using Function URL for a more -cost-effective -solution, as shown in +We use **API Gateway** combined with **AWS Lambda** to enable streaming responses for up to 15 minutes, as shown in this [example](https://github.com/awslabs/aws-lambda-web-adapter/tree/main/examples/fastapi-response-streaming). ### Step 1: Set up your API Key @@ -107,9 +91,6 @@ this [example](https://github.com/awslabs/aws-lambda-web-adapter/tree/main/examp - ECR repository name (or use default: `swift-chat-api`) - Image tag (please use default: `latest`) - AWS region (the region you want to deploy, e.g.,: `us-east-1`) - - Deployment type: - - Option 1 (default): **AppRunner** - uses amd64 architecture - - Option 2: **Lambda** - uses arm64 architecture 4. The script will build and push the Docker image to your ECR repository. @@ -117,24 +98,21 @@ this [example](https://github.com/awslabs/aws-lambda-web-adapter/tree/main/examp ### Step 3: Deploy stack and get your API URL -1. Download the CloudFormation template you want to use: - - For App Runner: [SwiftChatAppRunner.template](https://github.com/aws-samples/swift-chat/blob/main/server/template/SwiftChatAppRunner.template) - - For Lambda: [SwiftChatLambda.template](https://github.com/aws-samples/swift-chat/blob/main/server/template/SwiftChatLambda.template) +1. Download the CloudFormation template: + - Lambda: [SwiftChatLambda.template](https://github.com/aws-samples/swift-chat/blob/main/server/template/SwiftChatLambda.template) 2. Go to [CloudFormation Console](https://console.aws.amazon.com/cloudformation/home#/stacks/create/template?stackName=SwiftChatAPI) and select **Upload a template file** under **Specify template**, then upload the template file you downloaded. (Make sure you are in the same region where your API Key was created.) 3. Click **Next**, On the "Specify stack details" page, provide the following information: - - **Stack name**: Keep the default "SwiftChatAPI" or change if needed - **ApiKeyParam**: Enter the parameter name you used for storing the API key (e.g., "SwiftChatAPIKey") - **ContainerImageUri**: Enter the ECR image URI from Step 2 output - - For App Runner, choose an **InstanceTypeParam** based on your needs 4. Click **Next**, Keep the "Configure stack options" page as default, Read the Capabilities and Check the "I acknowledge that AWS CloudFormation might create IAM resources" checkbox at the bottom. 5. Click **Next**, In the "Review and create" Review your configuration and click **Submit**. Wait about 3–5 minutes for the deployment to finish, then click the CloudFormation stack and go to **Outputs** tab, you -can find the **API URL** which looks like: `https://xxx.xxx.awsapprunner.com` or `https://xxx.lambda-url.xxx.on.aws` +can find the **API URL** which looks like: `https://xxx.execute-api.us-east-1.amazonaws.com/v1` ### Step 4: Open the App and setup with API URL and API Key @@ -271,10 +249,15 @@ can enable the **Use Proxy** option to forward your requests. ![](assets/animations/english_teacher.avif) -**Rich Markdown Support**: Paragraph, Code Blocks, Tables, LaTeX and More +**Rich Markdown Support**: Paragraph, Code Blocks, Tables, LaTeX, Mermaid and More ![](assets/markdown.avif) +
+ + +
+ We redesigned the UI with optimized font sizes and line spacing for a more elegant and clean presentation. All of these features are also seamlessly displayed on Android and macOS with native UI @@ -464,10 +447,14 @@ the [release notes](https://github.com/aws-samples/swift-chat/releases) to see i ### Upgrade API -- **For AppRunner**: Click and open [App Runner Services](https://console.aws.amazon.com/apprunner/home#/services) page, - find and open `swiftchat-api`, click top right **Deploy** button. -- **For Lambda**: Click and open [Lambda Services](https://console.aws.amazon.com/lambda/home#/functions), find and open - your Lambda which start with `SwiftChatLambda-xxx`, click the **Deploy new image** button and click Save. +1. First, re-run the build script to update the image: + ```bash + cd server/scripts + bash ./push-to-ecr.sh + ``` + +2. Click and open [Lambda Services](https://console.aws.amazon.com/lambda/home#/functions), find and open + your Lambda which starts with `SwiftChatAPILambda-xxx`, click the **Deploy new image** button and click Save. ## Security diff --git a/README_CN.md b/README_CN.md index b4a05782..5a0bf280 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,18 +15,11 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https ![](assets/promo.avif) ### 新功能 🔥 - +- 🚀 支持网络搜索,获取实时信息(自 v2.7.0 起)。 +- 🚀 支持创建应用,生成并预览迷你 Web 应用(自 v2.7.0 起)。 +- 🚀 支持图片画廊,浏览和管理生成的图片(自 v2.7.0 起)。 - 🚀 支持流式渲染 Mermaid 图表(自 v2.6.0 起)。 -
- - -
- 🚀 支持使用 Bedrock API Key 连接 Amazon Bedrock 模型(自 v2.5.0 起)。 -- 🚀 支持虚拟试衣功能,自动识别衣服、裤子、鞋子并试穿(自 v2.5.0 起)。 -- 🚀 支持 macOS 快捷键操作(自 v2.5.0 起)。 - - 使用 `Shift + Enter`、`Control + Enter` 或 `Option + Enter` 添加换行。 - - 使用 `⌘ + V` 从剪贴板添加图片(截图)、视频或文档。 - - 使用 `⌘ + N` 打开多个 Mac 窗口进行并行操作。 ## 📱 快速下载 @@ -36,10 +29,6 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https ## Amazon Bedrock 入门指南 -### 前置条件 - -点击 [Amazon Bedrock 模型访问](https://console.aws.amazon.com/bedrock/home#/modelaccess) 启用您的模型访问权限。 - ### 配置 您可以选择以下两种配置方法中的一种 @@ -60,12 +49,9 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https ### 架构 -![](/assets/architecture.avif) - -默认情况下,我们使用 **AWS App Runner**,它通常用于托管 Python FastAPI 服务器,提供高性能、可扩展性和低延迟。 +![](/assets/architecture.png) -或者,我们提供用 **AWS Lambda** 使用 Function URL 替代 App Runner -的选项,以获得更具成本效益的解决方案,如此 [示例](https://github.com/awslabs/aws-lambda-web-adapter/tree/main/examples/fastapi-response-streaming) +我们提供用 **API Gateway** 与 **AWS Lambda** 结合的方式,实现最长15分钟的流式传输,如此 [示例](https://github.com/awslabs/aws-lambda-web-adapter/tree/main/examples/fastapi-response-streaming) 所示。 ### 步骤 1:设置您的 API 密钥 @@ -100,9 +86,6 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https - ECR 仓库名称(或使用默认值:`swift-chat-api`) - 镜像标签(请使用默认值:`latest`) - AWS 区域(填写你希望部署的区域,例如:`us-east-1`) - - 部署类型: - - 选项 1(默认):**AppRunner** - 使用 amd64 架构 - - 选项 2:**Lambda** - 使用 arm64 架构 4. 脚本将构建并推送 Docker 镜像到您的 ECR 仓库。 @@ -110,22 +93,19 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https ### 步骤 3:部署堆栈并获取 API URL -1. 下载您想使用的 CloudFormation 模板: - - App Runner:[SwiftChatAppRunner.template](https://github.com/aws-samples/swift-chat/blob/main/server/template/SwiftChatAppRunner.template) +1. 下载 CloudFormation 模板: - Lambda:[SwiftChatLambda.template](https://github.com/aws-samples/swift-chat/blob/main/server/template/SwiftChatLambda.template) 2. 前往 [CloudFormation 控制台](https://console.aws.amazon.com/cloudformation/home#/stacks/create/template?stackName=SwiftChatAPI),在**指定模板**下选择**上传模板文件**,然后上传您下载的模板文件。(确保您所在的区域与创建 API Key 的区域相同。) 3. 点击 **下一步**,在"指定堆栈详细信息"页面,提供以下信息: - - **Stack name**:保持默认的 "SwiftChatAPI" 或根据需要更改 - **ApiKeyParam**:输入您用于存储 API 密钥的参数名称(例如 "SwiftChatAPIKey") - **ContainerImageUri**:输入步骤 2 输出的 ECR 镜像 URI - - 对于 App Runner,根据您的需求选择 **InstanceTypeParam** 4. 点击 **下一步**,保持"配置堆栈选项"页面为默认,阅读功能并勾选底部的"我确认 AWS CloudFormation 可能会创建 IAM 资源"复选框。 5. 点击 **下一步**,在"审核并创建"中检查您的配置并点击 **提交**。 -等待约 3-5 分钟完成部署,然后点击 CloudFormation 堆栈并转到 **输出** 选项卡,您可以找到 **API URL**,类似于:`https://xxx.xxx.awsapprunner.com` 或 `https://xxx.lambda-url.xxx.on.aws` +等待约 3-5 分钟完成部署,然后点击 CloudFormation 堆栈并转到 **输出** 选项卡,您可以找到 **API URL**,类似于:`https://xxx.execute-api.us-east-1.amazonaws.com/v1` ### 步骤 4:打开应用并使用 API URL 和 API Key 进行设置 @@ -249,7 +229,7 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https
- +
@@ -258,10 +238,15 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https ![](assets/animations/english_teacher.avif) -**丰富的 Markdown 支持**:段落、代码块、表格、LaTeX 等 +**丰富的 Markdown 支持**:段落、代码块、表格、LaTeX、Mermaid 等 ![](assets/markdown.avif) +
+ + +
+ 我们重新设计了 UI,优化了字体大小和行间距,提供更优雅、清洁的展示效果。 所有这些功能也在 Android 和 macOS 上以原生 UI 无缝显示 @@ -441,10 +426,14 @@ npm run ios ### 升级 API -- **对于 AppRunner**:点击打开 [App Runner 服务](https://console.aws.amazon.com/apprunner/home#/services) 页面,找到并打开 - `swiftchat-api`,点击右上角 **部署** 按钮。 -- **对于 Lambda**:点击打开 [Lambda 服务](https://console.aws.amazon.com/lambda/home#/functions) 页面,找到并打开以 - `SwiftChatLambda-xxx` 开头的 Lambda,点击 **部署新镜像** 按钮并点击保存。 +1. 首先重新运行构建脚本以更新镜像: + ```bash + cd server/scripts + bash ./push-to-ecr.sh + ``` + +2. 点击打开 [Lambda 服务](https://console.aws.amazon.com/lambda/home#/functions) 页面,找到并打开以 + `SwiftChatAPILambda-xxx` 开头的 Lambda,点击 **部署新镜像** 按钮并点击保存。 ## 安全 diff --git a/assets/architecture.avif b/assets/architecture.avif deleted file mode 100644 index b904b50f..00000000 Binary files a/assets/architecture.avif and /dev/null differ diff --git a/assets/architecture.png b/assets/architecture.png new file mode 100644 index 00000000..5cd71514 Binary files /dev/null and b/assets/architecture.png differ diff --git a/react-native/android/app/src/main/java/com/aws/swiftchat/MainApplication.kt b/react-native/android/app/src/main/java/com/aws/swiftchat/MainApplication.kt index fca7ed48..52a95f8d 100644 --- a/react-native/android/app/src/main/java/com/aws/swiftchat/MainApplication.kt +++ b/react-native/android/app/src/main/java/com/aws/swiftchat/MainApplication.kt @@ -19,7 +19,7 @@ class MainApplication : Application(), ReactApplication { override fun getPackages(): List = PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) + add(NavigationBarPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/react-native/android/app/src/main/java/com/aws/swiftchat/NavigationBarModule.kt b/react-native/android/app/src/main/java/com/aws/swiftchat/NavigationBarModule.kt new file mode 100644 index 00000000..eb071f46 --- /dev/null +++ b/react-native/android/app/src/main/java/com/aws/swiftchat/NavigationBarModule.kt @@ -0,0 +1,72 @@ +package com.aws.swiftchat + +import android.graphics.Color +import android.os.Build +import android.view.View +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +class NavigationBarModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "NavigationBarModule" + + @ReactMethod + fun setImmersiveMode(enabled: Boolean) { + val activity = currentActivity ?: return + + activity.runOnUiThread { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (enabled) { + // Make navigation bar transparent and content extend behind it + activity.window.setDecorFitsSystemWindows(false) + activity.window.navigationBarColor = Color.TRANSPARENT + } else { + activity.window.setDecorFitsSystemWindows(true) + } + } else { + @Suppress("DEPRECATION") + if (enabled) { + activity.window.decorView.systemUiVisibility = + activity.window.decorView.systemUiVisibility or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + activity.window.navigationBarColor = Color.TRANSPARENT + } else { + activity.window.decorView.systemUiVisibility = + activity.window.decorView.systemUiVisibility and + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv() and + View.SYSTEM_UI_FLAG_LAYOUT_STABLE.inv() + } + } + } + } + + @ReactMethod + fun resetToDefault() { + val activity = currentActivity ?: return + + activity.runOnUiThread { + // Trigger MainActivity's updateNavigationBarColor + if (activity is MainActivity) { + // Reset immersive mode first + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + activity.window.setDecorFitsSystemWindows(true) + } else { + @Suppress("DEPRECATION") + activity.window.decorView.systemUiVisibility = + activity.window.decorView.systemUiVisibility and + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv() and + View.SYSTEM_UI_FLAG_LAYOUT_STABLE.inv() + } + } + + // Restore default navigation bar color based on theme + val isDarkMode = activity.resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK == + android.content.res.Configuration.UI_MODE_NIGHT_YES + + activity.window.navigationBarColor = if (isDarkMode) Color.BLACK else Color.WHITE + } + } +} diff --git a/react-native/android/app/src/main/java/com/aws/swiftchat/NavigationBarPackage.kt b/react-native/android/app/src/main/java/com/aws/swiftchat/NavigationBarPackage.kt new file mode 100644 index 00000000..9fcab8f4 --- /dev/null +++ b/react-native/android/app/src/main/java/com/aws/swiftchat/NavigationBarPackage.kt @@ -0,0 +1,16 @@ +package com.aws.swiftchat + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class NavigationBarPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(NavigationBarModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/react-native/ios/Modules/VoiceChat/VoiceChatModule.swift b/react-native/ios/Modules/VoiceChat/VoiceChatModule.swift index d10e0294..6012b1aa 100644 --- a/react-native/ios/Modules/VoiceChat/VoiceChatModule.swift +++ b/react-native/ios/Modules/VoiceChat/VoiceChatModule.swift @@ -8,13 +8,28 @@ import Foundation import React +// Wrapper to make RCT callbacks Sendable +struct SendableResolve: @unchecked Sendable { + let block: RCTPromiseResolveBlock + func callAsFunction(_ value: Any?) { + block(value) + } +} + +struct SendableReject: @unchecked Sendable { + let block: RCTPromiseRejectBlock + func callAsFunction(_ code: String?, _ message: String?, _ error: Error?) { + block(code, message, error) + } +} + @objc(VoiceChatModule) -class VoiceChatModule: RCTEventEmitter { +final class VoiceChatModule: RCTEventEmitter, @unchecked Sendable { private let conversationManager = ConversationManager() private var hasListeners = false - + // MARK: - RCTEventEmitter Overrides - + override func supportedEvents() -> [String] { return [ "onTranscriptReceived", @@ -22,21 +37,21 @@ class VoiceChatModule: RCTEventEmitter { "onAudioLevelChanged" ] } - + override func startObserving() { hasListeners = true } - + override func stopObserving() { hasListeners = false } - + override static func requiresMainQueueSetup() -> Bool { return false } - + // MARK: - Module Methods - + @objc(initialize:withResolver:withRejecter:) func initialize(_ config: [String: Any], resolve: @escaping RCTPromiseResolveBlock, @@ -50,34 +65,38 @@ class VoiceChatModule: RCTEventEmitter { reject("INVALID_CONFIG", "Invalid credential provided", nil) return } - + // Get sessionToken (optional) let sessionToken = config["sessionToken"] as? String - + // Set up callbacks setupCallbacks() - - // Initialize conversation manager + + // Wrap callbacks to make them Sendable + let safeResolve = SendableResolve(block: resolve) + let safeReject = SendableReject(block: reject) + let manager = conversationManager + Task { do { - try conversationManager.initialize( + try manager.initialize( region: region, accessKey: accessKey, secretKey: secretKey, sessionToken: sessionToken, apiKey: apiKey ) - DispatchQueue.main.async { - resolve(["success": true]) + await MainActor.run { + safeResolve(["success": true]) } } catch { - DispatchQueue.main.async { - reject("INIT_ERROR", "Failed to initialize: \(error)", error) + await MainActor.run { + safeReject("INIT_ERROR", "Failed to initialize: \(error)", error) } } } } - + @objc(startConversation:withVoiceId:withAllowInterruption:withResolver:withRejecter:) func startConversation(_ systemPrompt: String, voiceId: String, @@ -85,41 +104,49 @@ class VoiceChatModule: RCTEventEmitter { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + let safeResolve = SendableResolve(block: resolve) + let safeReject = SendableReject(block: reject) + let manager = conversationManager + Task { do { - try await conversationManager.startConversation(systemPrompt: systemPrompt, - voiceId: voiceId, - allowInterruption: allowInterruption) - DispatchQueue.main.async { - resolve(["success": true]) + try await manager.startConversation(systemPrompt: systemPrompt, + voiceId: voiceId, + allowInterruption: allowInterruption) + await MainActor.run { + safeResolve(["success": true]) } } catch { - DispatchQueue.main.async { - reject("CONVERSATION_ERROR", "Failed to start conversation: \(error)", error) + await MainActor.run { + safeReject("CONVERSATION_ERROR", "Failed to start conversation: \(error)", error) } } } } - - + + @objc(endConversation:withRejecter:) func endConversation(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + let safeResolve = SendableResolve(block: resolve) + let safeReject = SendableReject(block: reject) + let manager = conversationManager + Task { do { - try await conversationManager.endConversation() - DispatchQueue.main.async { - resolve(["success": true]) + try await manager.endConversation() + await MainActor.run { + safeResolve(["success": true]) } } catch { - DispatchQueue.main.async { - reject("CONVERSATION_ERROR", "Failed to end conversation: \(error)", error) + await MainActor.run { + safeReject("CONVERSATION_ERROR", "Failed to end conversation: \(error)", error) } } } } - + @objc(updateCredentials:withResolver:withRejecter:) func updateCredentials(_ config: [String: Any], resolve: @escaping RCTPromiseResolveBlock, @@ -133,10 +160,10 @@ class VoiceChatModule: RCTEventEmitter { reject("INVALID_CONFIG", "Invalid credential provided", nil) return } - + // Get sessionToken (optional) let sessionToken = config["sessionToken"] as? String - + // Update credentials conversationManager.updateCredentials( region: region, @@ -145,19 +172,19 @@ class VoiceChatModule: RCTEventEmitter { sessionToken: sessionToken, apiKey: apiKey ) - + resolve(["success": true]) } - + // MARK: - Private Methods - + private func setupCallbacks() { // Handle transcripts conversationManager.onTranscriptReceived = { [weak self] role, text in guard let self = self, self.hasListeners else { return } - - DispatchQueue.main.async { - self.sendEvent( + + DispatchQueue.main.async { [weak self] in + self?.sendEvent( withName: "onTranscriptReceived", body: [ "role": role, @@ -166,27 +193,28 @@ class VoiceChatModule: RCTEventEmitter { ) } } - + // Handle errors conversationManager.onError = { [weak self] error in guard let self = self, self.hasListeners else { return } - - DispatchQueue.main.async { - self.sendEvent( + let errorMessage = "\(error)" + + DispatchQueue.main.async { [weak self] in + self?.sendEvent( withName: "onError", body: [ - "message": "\(error)" + "message": errorMessage ] ) } } - + // Handle audio level changes conversationManager.onAudioLevelChanged = { [weak self] source, level in guard let self = self, self.hasListeners else { return } - - DispatchQueue.main.async { - self.sendEvent( + + DispatchQueue.main.async { [weak self] in + self?.sendEvent( withName: "onAudioLevelChanged", body: [ "source": source, diff --git a/react-native/ios/PlatformModule.m b/react-native/ios/PlatformModule.m index e293da9a..1c8ac52f 100644 --- a/react-native/ios/PlatformModule.m +++ b/react-native/ios/PlatformModule.m @@ -11,9 +11,12 @@ - (NSDictionary *)constantsToExport #else BOOL isMacCatalyst = NO; #endif - + + NSString *buildNumber = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; + return @{ - @"isMacCatalyst": @(isMacCatalyst) + @"isMacCatalyst": @(isMacCatalyst), + @"buildNumber": buildNumber ?: @"" }; } diff --git a/react-native/ios/Podfile b/react-native/ios/Podfile index 5bd24a37..b9cda007 100644 --- a/react-native/ios/Podfile +++ b/react-native/ios/Podfile @@ -36,5 +36,15 @@ target 'SwiftChat' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + # Fix Swift version mismatch - use Swift 5.0 for pods that don't support Swift 6.0 + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if target.name == 'react-native-compressor' + config.build_settings['SWIFT_VERSION'] = '5.0' + else + config.build_settings['SWIFT_VERSION'] = '6.0' + end + end + end end end diff --git a/react-native/ios/Podfile.lock b/react-native/ios/Podfile.lock index 6c853b3f..6637e1f8 100644 --- a/react-native/ios/Podfile.lock +++ b/react-native/ios/Podfile.lock @@ -1003,6 +1003,8 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-keep-awake (1.4.0): + - React-Core - react-native-mmkv (2.12.2): - DoubleConversion - glog @@ -1393,6 +1395,7 @@ DEPENDENCIES: - react-native-document-picker (from `../node_modules/react-native-document-picker`) - react-native-get-random-values (from `../node_modules/react-native-get-random-values`) - react-native-image-picker (from `../node_modules/react-native-image-picker`) + - "react-native-keep-awake (from `../node_modules/@sayem314/react-native-keep-awake`)" - react-native-mmkv (from `../node_modules/react-native-mmkv`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-webview (from `../node_modules/react-native-webview`) @@ -1506,6 +1509,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-get-random-values" react-native-image-picker: :path: "../node_modules/react-native-image-picker" + react-native-keep-awake: + :path: "../node_modules/@sayem314/react-native-keep-awake" react-native-mmkv: :path: "../node_modules/react-native-mmkv" react-native-safe-area-context: @@ -1616,6 +1621,7 @@ SPEC CHECKSUMS: react-native-document-picker: 451699da81cba8b40b596b8076019a4deb86f46e react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba react-native-image-picker: f1006d8935a3bc0baf8157faaa7857c76a77c8bb + react-native-keep-awake: abdad45fbf0da6435d409fafa6a74c8a934a6f75 react-native-mmkv: f8155c2efbe795cb0c7586d00ff484b1c9388af0 react-native-safe-area-context: b72c4611af2e86d80a59ac76279043d8f75f454c react-native-webview: b836f1f162b87b5b8351611b5d5299f2b699360a @@ -1654,6 +1660,6 @@ SPEC CHECKSUMS: SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 348f8b538c3ed4423eb58a8e5730feec50bce372 -PODFILE CHECKSUM: eed3e49f72b6465b4551e72df9de6b83230c91ca +PODFILE CHECKSUM: b1537eedcc91cb3f96078b5b2842c464a4102b64 COCOAPODS: 1.16.2 diff --git a/react-native/ios/RCTTextInputPatch.mm b/react-native/ios/RCTTextInputPatch.mm index a33fe65b..0d213a4e 100644 --- a/react-native/ios/RCTTextInputPatch.mm +++ b/react-native/ios/RCTTextInputPatch.mm @@ -154,7 +154,7 @@ - (BOOL)swizzled_textInputShouldSubmitOnReturn RCTBaseTextInputView *textInputView = (RCTBaseTextInputView *)self; if (altKeyPressed || shiftPressed) { - // Alt+Enter logic - insert newline + // Alt+Enter or Shift+Enter logic - insert newline id backedTextInputView = textInputView.backedTextInputView; @@ -193,6 +193,11 @@ - (BOOL)swizzled_textInputShouldSubmitOnReturn // Trigger text change event [textInputView textInputDidChange]; + // Reset modifier key states after processing + altKeyPressed = NO; + shiftPressed = NO; + commandPressed = NO; + // Return NO to prevent submission when Alt is pressed return NO; } else { @@ -215,6 +220,11 @@ - (BOOL)swizzled_textInputShouldSubmitOnReturn } } + // Reset modifier key states after processing to prevent state leakage + altKeyPressed = NO; + shiftPressed = NO; + commandPressed = NO; + return shouldSubmit; } } diff --git a/react-native/ios/Services/AudioManager.swift b/react-native/ios/Services/AudioManager.swift index 6b938983..27aedb5e 100644 --- a/react-native/ios/Services/AudioManager.swift +++ b/react-native/ios/Services/AudioManager.swift @@ -8,9 +8,9 @@ enum AudioError: Error { case microphoneAccessDenied(String) } -class AudioManager: NSObject { +final class AudioManager: NSObject, @unchecked Sendable { // Basic components - private var audioSession = AVAudioSession.sharedInstance() + private let audioSession = AVAudioSession.sharedInstance() private var audioRecorder: AVAudioRecorder? // Audio engine components diff --git a/react-native/ios/Services/ConversationManager.swift b/react-native/ios/Services/ConversationManager.swift index 64340678..27e29071 100644 --- a/react-native/ios/Services/ConversationManager.swift +++ b/react-native/ios/Services/ConversationManager.swift @@ -7,7 +7,7 @@ import Foundation -class ConversationManager { +final class ConversationManager: @unchecked Sendable { // Services private var audioManager: AudioManager! private var novaSonicService: NovaSonicService? diff --git a/react-native/ios/Services/NovaSonicService.swift b/react-native/ios/Services/NovaSonicService.swift index 59bf8419..cf23266f 100644 --- a/react-native/ios/Services/NovaSonicService.swift +++ b/react-native/ios/Services/NovaSonicService.swift @@ -21,7 +21,7 @@ enum NovaSonicError: Error { case microphoneError(String) } -class NovaSonicService { +final class NovaSonicService: @unchecked Sendable { // AWS Configuration private var client: BedrockRuntimeClient? private var region: String @@ -29,7 +29,7 @@ class NovaSonicService { private var secretKey: String private var sessionToken: String? private var apiKey: String? - private var modelId: String = "amazon.nova-sonic-v1:0" + private var modelId: String = "amazon.nova-2-sonic-v1:0" // Stream state private var isActive: Bool = false diff --git a/react-native/ios/SwiftChat.xcodeproj/project.pbxproj b/react-native/ios/SwiftChat.xcodeproj/project.pbxproj index f2c23d79..b2ff1062 100644 --- a/react-native/ios/SwiftChat.xcodeproj/project.pbxproj +++ b/react-native/ios/SwiftChat.xcodeproj/project.pbxproj @@ -601,7 +601,7 @@ SUPPORTS_MACCATALYST = YES; SWIFT_OBJC_BRIDGING_HEADER = "SwiftChat-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2,6"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -640,7 +640,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; SWIFT_OBJC_BRIDGING_HEADER = "SwiftChat-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2,6"; VERSIONING_SYSTEM = "apple-generic"; }; @@ -719,7 +719,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -791,7 +794,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/react-native/ios/SwiftChat/PlatformModule.m b/react-native/ios/SwiftChat/PlatformModule.m index e21a1162..e8bc6c8b 100644 --- a/react-native/ios/SwiftChat/PlatformModule.m +++ b/react-native/ios/SwiftChat/PlatformModule.m @@ -11,9 +11,12 @@ - (NSDictionary *)constantsToExport #else BOOL isMacCatalyst = NO; #endif - + + NSString *buildNumber = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; + return @{ - @"isMacCatalyst": @(isMacCatalyst) + @"isMacCatalyst": @(isMacCatalyst), + @"buildNumber": buildNumber ?: @"" }; } diff --git a/react-native/ios/SwiftChat/SwiftChat.entitlements b/react-native/ios/SwiftChat/SwiftChat.entitlements index 2b6edc3b..c506c0c7 100644 --- a/react-native/ios/SwiftChat/SwiftChat.entitlements +++ b/react-native/ios/SwiftChat/SwiftChat.entitlements @@ -6,6 +6,8 @@ com.apple.security.device.audio-input + com.apple.security.files.downloads.read-write + com.apple.security.files.user-selected.read-only com.apple.security.network.client diff --git a/react-native/package-lock.json b/react-native/package-lock.json index 9d38aaa9..2a6331a0 100644 --- a/react-native/package-lock.json +++ b/react-native/package-lock.json @@ -11,10 +11,14 @@ "license": "Apache-2.0", "dependencies": { "@bwjohns4/react-native-draggable-flatlist": "^4.0.1-patch", + "@mozilla/readability": "^0.6.0", "@react-native-clipboard/clipboard": "^1.14.1", "@react-navigation/drawer": "^7.1.1", "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.2.0", + "@sayem314/react-native-keep-awake": "^1.4.0", + "jsonrepair": "^3.13.1", + "linkedom": "^0.18.12", "react": "18.2.0", "react-native": "0.74.1", "react-native-code-highlighter": "^1.2.2", @@ -43,7 +47,8 @@ "react-native-svg": "^15.4.0", "react-native-toast-message": "^2.2.1", "react-native-webview": "^13.16.0", - "react-syntax-highlighter": "^15.5.0" + "react-syntax-highlighter": "^15.5.0", + "turndown": "^7.2.2" }, "devDependencies": { "@babel/core": "^7.26.10", @@ -58,6 +63,7 @@ "@types/react-native-table-component": "^1.2.8", "@types/react-syntax-highlighter": "^15.5.13", "@types/react-test-renderer": "^18.0.0", + "@types/turndown": "^5.0.6", "@types/uuid": "3.4.0", "babel-jest": "^29.6.3", "core-js": "^3.37.1", @@ -2785,6 +2791,21 @@ "integrity": "sha512-+Cy9zFqdQgdAbMK1dpm7B+3DUnrByai0Tq6XG9v737HJpW6G1EiNNbTuFeXdPWyGaq6FIx9jxm/SUcxA6/Rxxg==", "peer": true }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mozilla/readability": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", + "integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -4248,6 +4269,16 @@ "node": ">=10" } }, + "node_modules/@sayem314/react-native-keep-awake": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@sayem314/react-native-keep-awake/-/react-native-keep-awake-1.4.0.tgz", + "integrity": "sha512-EOg6im5sSuwPC3hamXjW2kb6KB1HuluivdGzjvIRAeeEH7cVyaBTcSaV3SZerQRImTxoaVLMnD1FOEyQQK6etg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/sayem314" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -4477,6 +4508,13 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", @@ -6243,6 +6281,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -6581,9 +6625,10 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -6659,6 +6704,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/envinfo": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz", @@ -8560,6 +8617,25 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -10083,6 +10159,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/jsonrepair": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz", + "integrity": "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -10193,6 +10278,36 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/linkedom/node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -14146,14 +14261,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/refractor/node_modules/prismjs": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", - "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", - "engines": { - "node": ">=6" - } - }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -15334,6 +15441,15 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -15452,6 +15568,12 @@ "node": ">=12.20" } }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "license": "ISC" + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -18046,6 +18168,16 @@ "integrity": "sha512-+Cy9zFqdQgdAbMK1dpm7B+3DUnrByai0Tq6XG9v737HJpW6G1EiNNbTuFeXdPWyGaq6FIx9jxm/SUcxA6/Rxxg==", "peer": true }, + "@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==" + }, + "@mozilla/readability": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", + "integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==" + }, "@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -19184,6 +19316,11 @@ } } }, + "@sayem314/react-native-keep-awake": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@sayem314/react-native-keep-awake/-/react-native-keep-awake-1.4.0.tgz", + "integrity": "sha512-EOg6im5sSuwPC3hamXjW2kb6KB1HuluivdGzjvIRAeeEH7cVyaBTcSaV3SZerQRImTxoaVLMnD1FOEyQQK6etg==" + }, "@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -19410,6 +19547,12 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true + }, "@types/unist": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", @@ -20665,6 +20808,11 @@ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" }, + "cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==" + }, "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -20898,9 +21046,9 @@ } }, "domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "requires": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -20961,6 +21109,11 @@ "once": "^1.4.0" } }, + "entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" + }, "envinfo": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz", @@ -22325,6 +22478,17 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, "http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -23414,6 +23578,11 @@ "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", "dev": true }, + "jsonrepair": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz", + "integrity": "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==" + }, "jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -23508,6 +23677,25 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "requires": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "dependencies": { + "html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" + } + } + }, "load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -26472,7 +26660,7 @@ "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", - "prismjs": "^1.27.0", + "prismjs": "^1.30.0", "refractor": "^3.6.0" } }, @@ -26555,14 +26743,7 @@ "requires": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", - "prismjs": "~1.27.0" - }, - "dependencies": { - "prismjs": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", - "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==" - } + "prismjs": "^1.30.0" } }, "regenerate": { @@ -27466,6 +27647,14 @@ } } }, + "turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "requires": { + "@mixmark-io/domino": "^2.2.0" + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -27544,6 +27733,11 @@ "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true }, + "uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==" + }, "unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/react-native/package.json b/react-native/package.json index 85d78c68..b79c5919 100644 --- a/react-native/package.json +++ b/react-native/package.json @@ -15,10 +15,14 @@ }, "dependencies": { "@bwjohns4/react-native-draggable-flatlist": "^4.0.1-patch", + "@mozilla/readability": "^0.6.0", "@react-native-clipboard/clipboard": "^1.14.1", "@react-navigation/drawer": "^7.1.1", "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.2.0", + "@sayem314/react-native-keep-awake": "^1.4.0", + "jsonrepair": "^3.13.1", + "linkedom": "^0.18.12", "react": "18.2.0", "react-native": "0.74.1", "react-native-code-highlighter": "^1.2.2", @@ -47,7 +51,8 @@ "react-native-svg": "^15.4.0", "react-native-toast-message": "^2.2.1", "react-native-webview": "^13.16.0", - "react-syntax-highlighter": "^15.5.0" + "react-syntax-highlighter": "^15.5.0", + "turndown": "^7.2.2" }, "devDependencies": { "@babel/core": "^7.26.10", @@ -62,6 +67,7 @@ "@types/react-native-table-component": "^1.2.8", "@types/react-syntax-highlighter": "^15.5.13", "@types/react-test-renderer": "^18.0.0", + "@types/turndown": "^5.0.6", "@types/uuid": "3.4.0", "babel-jest": "^29.6.3", "core-js": "^3.37.1", @@ -77,7 +83,8 @@ "@react-native-clipboard/clipboard": { "react-native": "~0.74.1", "react-native-windows": "~0.74.1" - } + }, + "prismjs": "^1.30.0" }, "engines": { "node": ">=18" diff --git a/react-native/src/App.tsx b/react-native/src/App.tsx index f82f7b2b..e3ad2ba5 100644 --- a/react-native/src/App.tsx +++ b/react-native/src/App.tsx @@ -15,10 +15,15 @@ import Toast from 'react-native-toast-message'; import TokenUsageScreen from './settings/TokenUsageScreen.tsx'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import PromptScreen from './prompt/PromptScreen.tsx'; +import AppGalleryScreen from './app/AppGalleryScreen.tsx'; +import AppViewerScreen from './app/AppViewerScreen.tsx'; +import CreateAppScreen from './app/CreateAppScreen.tsx'; +import ImageGalleryScreen from './image/ImageGalleryScreen.tsx'; import { isAndroid, isMacCatalyst } from './utils/PlatformUtils'; import { ThemeProvider, useTheme } from './theme'; import { configureErrorHandling } from './utils/ErrorUtils'; import { migrateOpenAICompatConfig } from './storage/StorageUtils.ts'; +import { SearchWebView } from './websearch/components/SearchWebView'; export const isMac = isMacCatalyst; const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); @@ -59,6 +64,8 @@ const DrawerNavigator = () => { drawerContent={renderCustomDrawerContent}> + + ); }; @@ -99,6 +106,37 @@ const AppNavigator = () => { headerTintColor: colors.text, }} /> + { + const params = route.params as RouteParamList['AppViewer']; + return { + title: params?.app?.name ?? 'App', + contentStyle: { + height: isMac ? 66 : undefined, + backgroundColor: '#000000', + }, + headerTitleAlign: 'center', + headerStyle: { backgroundColor: colors.background }, + headerTintColor: colors.text, + }; + }} + /> + ); }; @@ -117,6 +155,9 @@ const AppWithTheme = () => { }}> + + {/* WebView用于web search */} + ); }; diff --git a/react-native/src/api/bedrock-api-key-image.ts b/react-native/src/api/bedrock-api-key-image.ts index cb875694..6fda5e83 100644 --- a/react-native/src/api/bedrock-api-key-image.ts +++ b/react-native/src/api/bedrock-api-key-image.ts @@ -319,7 +319,7 @@ async function invokeBedrockModel( } return ''; } catch (error) { - console.log(`Error invoking model ${modelId}:`, error); + console.log('Error invoking model:', modelId, error); throw error; } } diff --git a/react-native/src/api/bedrock-api-key.ts b/react-native/src/api/bedrock-api-key.ts index 37404aa3..170ed01e 100644 --- a/react-native/src/api/bedrock-api-key.ts +++ b/react-native/src/api/bedrock-api-key.ts @@ -199,7 +199,7 @@ function parseChunk(part: string) { lastUsage = content.usage; } } catch (innerError) { - console.log('DataChunk parse error:' + innerError, part); + console.log('DataChunk parse error:', innerError, part); return { reasoning: reasoning, text: part, diff --git a/react-native/src/api/bedrock-api.ts b/react-native/src/api/bedrock-api.ts index f73565b7..aa27b93e 100644 --- a/react-native/src/api/bedrock-api.ts +++ b/react-native/src/api/bedrock-api.ts @@ -532,7 +532,7 @@ function parseChunk(part: string) { lastUsage = content.usage; } } catch (innerError) { - console.log('DataChunk parse error:' + innerError, part); + console.log('DataChunk parse error:', innerError, part); return { reasoning: combinedReasoning, text: part, diff --git a/react-native/src/app/AppGalleryScreen.tsx b/react-native/src/app/AppGalleryScreen.tsx new file mode 100644 index 00000000..65e1d454 --- /dev/null +++ b/react-native/src/app/AppGalleryScreen.tsx @@ -0,0 +1,750 @@ +import React, { useCallback, useState, useEffect, useRef } from 'react'; +import { + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, + FlatList, + Image, + Alert, + Platform, + Dimensions, + Modal, + TextInput, + GestureResponderEvent, + Animated, + Easing, + ImageSourcePropType, +} from 'react-native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { DrawerNavigationProp } from '@react-navigation/drawer'; +import { RouteParamList } from '../types/RouteTypes'; +import { + getSavedApps, + deleteApp, + getAppById, + AppMetadata, + pinApp, + renameApp, +} from '../storage/StorageUtils'; +import { CustomHeaderRightButton } from '../chat/component/CustomHeaderRightButton'; +import { useTheme, ColorScheme } from '../theme'; +import RNFS from 'react-native-fs'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { showInfo } from '../chat/util/ToastUtils'; +import { isMac } from '../App'; +import Share from 'react-native-share'; + +type NavigationProp = DrawerNavigationProp; + +const getNumColumns = (width: number) => (width > 434 ? 4 : 2); +const MENU_HEIGHT = 280; // 5 items * 56px each + +// Context menu item +interface MenuItemProps { + label: string; + icon: ImageSourcePropType; + onPress: () => void; + isDestructive?: boolean; + rotateIcon?: boolean; + colors: ColorScheme; + isLast?: boolean; +} + +const MenuItem: React.FC = ({ + label, + icon, + onPress, + isDestructive, + rotateIcon, + colors, + isLast, +}) => { + const styles = menuStyles(colors); + return ( + + + {label} + + + + ); +}; + +const menuStyles = (colors: ColorScheme) => + StyleSheet.create({ + menuItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: 20, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + menuItemLast: { + borderBottomWidth: 0, + }, + menuLabel: { + fontSize: 17, + color: colors.text, + }, + destructiveLabel: { + color: '#FF3B30', + }, + menuIcon: { + width: 20, + height: 20, + tintColor: colors.text, + }, + destructiveIcon: { + tintColor: '#FF3B30', + }, + rotatedIcon: { + transform: [{ rotate: '180deg' }], + }, + }); + +// Animated menu component +interface AnimatedMenuProps { + visible: boolean; + position: { x: number; y: number }; + expandUp: boolean; + onClose: () => void; + children: React.ReactNode; + colors: ColorScheme; +} + +const AnimatedMenu: React.FC = ({ + visible, + position, + expandUp, + onClose, + children, + colors, +}) => { + const scaleAnim = useRef(new Animated.Value(0)).current; + const opacityAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + Animated.parallel([ + Animated.timing(scaleAnim, { + toValue: 1, + duration: 150, + easing: Easing.out(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(opacityAnim, { + toValue: 1, + duration: 150, + easing: Easing.out(Easing.ease), + useNativeDriver: true, + }), + ]).start(); + } else { + scaleAnim.setValue(0); + opacityAnim.setValue(0); + } + }, [visible, scaleAnim, opacityAnim]); + + const screenW = Dimensions.get('window').width; + const menuWidth = 200; + let left = position.x - menuWidth / 2; + + if (left < 16) { + left = 16; + } + if (left + menuWidth > screenW - 16) { + left = screenW - menuWidth - 16; + } + + const menuContainerStyle = { + position: 'absolute' as const, + top: position.y, + left: left, + width: menuWidth, + backgroundColor: colors.card, + borderRadius: 14, + overflow: 'hidden' as const, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + opacity: opacityAnim, + transform: [{ scaleY: scaleAnim }], + }; + + return ( + + + + {children} + + + + ); +}; + +const animatedMenuStyles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + }, + transformOriginBottom: { + transformOrigin: 'bottom', + }, + transformOriginTop: { + transformOrigin: 'top', + }, +}); + +function AppGalleryScreen(): React.JSX.Element { + const navigation = useNavigation(); + const { colors, isDark } = useTheme(); + const [apps, setApps] = useState([]); + const [screenWidth, setScreenWidth] = useState( + Dimensions.get('window').width + ); + const numColumns = getNumColumns(screenWidth); + const styles = createStyles(colors, numColumns); + + // Context menu state + const [menuVisible, setMenuVisible] = useState(false); + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + const [menuExpandUp, setMenuExpandUp] = useState(false); + const [selectedApp, setSelectedApp] = useState(null); + + // Rename modal state + const [renameModalVisible, setRenameModalVisible] = useState(false); + const [newName, setNewName] = useState(''); + + // Listen for screen size changes + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setScreenWidth(window.width); + }); + return () => subscription?.remove(); + }, []); + + const loadApps = useCallback(() => { + const savedApps = getSavedApps(); + setApps(savedApps); + }, []); + + // Reload apps when screen comes into focus + useFocusEffect( + useCallback(() => { + const savedApps = getSavedApps(); + setApps(savedApps); + }, []) + ); + + React.useLayoutEffect(() => { + navigation.setOptions({ + // eslint-disable-next-line react/no-unstable-nested-components + headerRight: () => ( + navigation.navigate('CreateApp', {})} + imageSource={ + isDark + ? require('../assets/add_dark.png') + : require('../assets/add.png') + } + /> + ), + title: 'App Gallery', + }); + }, [navigation, isDark]); + + const handleLongPress = useCallback( + (app: AppMetadata, event: GestureResponderEvent) => { + const { pageX, pageY } = event.nativeEvent; + const screenHeight = Dimensions.get('window').height; + + // Determine if menu should expand up or down + const spaceBelow = screenHeight - pageY; + const shouldExpandUp = spaceBelow < MENU_HEIGHT + 50; + + let adjustedY = pageY; + if (shouldExpandUp) { + // Position menu above finger, menu will expand upward + adjustedY = pageY - MENU_HEIGHT; + if (adjustedY < 50) { + adjustedY = 50; + } + } else { + // Menu expands downward from finger position + if (pageY + MENU_HEIGHT > screenHeight - 50) { + adjustedY = screenHeight - MENU_HEIGHT - 50; + } + } + + setMenuPosition({ x: pageX, y: adjustedY }); + setMenuExpandUp(shouldExpandUp); + setSelectedApp(app); + setMenuVisible(true); + }, + [] + ); + + const closeMenu = useCallback(() => { + setMenuVisible(false); + setSelectedApp(null); + }, []); + + const handleRename = useCallback(() => { + if (selectedApp) { + setNewName(selectedApp.name); + setMenuVisible(false); + setRenameModalVisible(true); + } + }, [selectedApp]); + + const confirmRename = useCallback(() => { + if (selectedApp && newName.trim()) { + renameApp(selectedApp.id, newName.trim()); + loadApps(); + } + setRenameModalVisible(false); + setSelectedApp(null); + setNewName(''); + }, [selectedApp, newName, loadApps]); + + const handlePin = useCallback(() => { + if (selectedApp) { + pinApp(selectedApp.id); + loadApps(); + } + closeMenu(); + }, [selectedApp, loadApps, closeMenu]); + + const handleCopy = useCallback(() => { + if (selectedApp) { + const app = getAppById(selectedApp.id); + if (app) { + Clipboard.setString(app.htmlCode); + showInfo('Code copied'); + } + } + closeMenu(); + }, [selectedApp, closeMenu]); + + const handleSaveToFile = useCallback(async () => { + if (selectedApp) { + const app = getAppById(selectedApp.id); + if (app) { + try { + const fileName = `${app.name.replace( + /[^a-zA-Z0-9\u4e00-\u9fa5]/g, + '_' + )}.html`; + + if (isMac) { + // On Mac, save directly to Downloads folder + const downloadsPath = RNFS.DocumentDirectoryPath.replace( + '/Documents', + '/Downloads' + ); + const filePath = `${downloadsPath}/${fileName}`; + await RNFS.writeFile(filePath, app.htmlCode, 'utf8'); + showInfo('Saved to Downloads'); + } else if (Platform.OS === 'android') { + // On Android, save to Downloads folder + const filePath = `${RNFS.DownloadDirectoryPath}/${fileName}`; + await RNFS.writeFile(filePath, app.htmlCode, 'utf8'); + showInfo('Saved to Downloads'); + } else { + // On iOS mobile, save to Documents then use Share sheet + const filePath = `${RNFS.DocumentDirectoryPath}/${fileName}`; + await RNFS.writeFile(filePath, app.htmlCode, 'utf8'); + const shareOptions = { + url: filePath, + type: 'text/html', + title: 'Save HTML File', + }; + await Share.open(shareOptions); + } + } catch (error) { + console.error('Error saving file:', error); + // User cancelled share is not an error + if ((error as Error).message !== 'User did not share') { + Alert.alert('Error', 'Failed to save file'); + } + } + } + } + closeMenu(); + }, [selectedApp, closeMenu]); + + const handleDelete = useCallback(() => { + if (selectedApp) { + const app = selectedApp; + closeMenu(); + Alert.alert( + 'Delete App', + `Are you sure you want to delete "${app.name}"?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + if (app.screenshotPath) { + try { + const fullPath = + Platform.OS === 'ios' + ? `${RNFS.DocumentDirectoryPath}/${app.screenshotPath}` + : app.screenshotPath.replace('file://', ''); + const exists = await RNFS.exists(fullPath); + if (exists) { + await RNFS.unlink(fullPath); + } + } catch (error) { + console.log('Error deleting screenshot:', error); + } + } + deleteApp(app.id); + loadApps(); + }, + }, + ] + ); + } + }, [selectedApp, closeMenu, loadApps]); + + const handleOpenApp = useCallback( + (appMetadata: AppMetadata) => { + const app = getAppById(appMetadata.id); + if (app) { + navigation.navigate('AppViewer', { app }); + } + }, + [navigation] + ); + + const renderAppItem = useCallback( + ({ item }: { item: AppMetadata }) => { + const screenshotUri = item.screenshotPath + ? Platform.OS === 'ios' + ? `${RNFS.DocumentDirectoryPath}/${item.screenshotPath}` + : item.screenshotPath + : null; + return ( + + handleOpenApp(item)} + onLongPress={e => handleLongPress(item, e)} + activeOpacity={0.7}> + + {screenshotUri ? ( + + ) : ( + + No Preview + + )} + + + + {item.name} + + + {new Date(item.createdAt).toLocaleDateString()} + + + + + ); + }, + [styles, handleOpenApp, handleLongPress] + ); + + const renderEmptyState = useCallback( + () => ( + + No saved apps yet + + Generate HTML apps in chat and save them here + + + ), + [styles] + ); + + return ( + + item.id} + numColumns={numColumns} + contentContainerStyle={styles.listContainer} + columnWrapperStyle={apps.length > 1 ? styles.columnWrapper : undefined} + ListEmptyComponent={renderEmptyState} + /> + + {/* Context Menu */} + + + + + + + + + {/* Rename Modal */} + setRenameModalVisible(false)}> + setRenameModalVisible(false)}> + true}> + Rename App + + + { + setRenameModalVisible(false); + setSelectedApp(null); + setNewName(''); + }}> + Cancel + + + OK + + + + + + + ); +} + +const createStyles = (colors: ColorScheme, numColumns: number) => { + // 均分宽度,间距通过 paddingLeft 实现 + const cardWidthPercent = numColumns === 4 ? '25%' : '50%'; + + return StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: colors.background, + }, + listContainer: { + paddingTop: 12, + paddingRight: 6, + paddingBottom: 12, + paddingLeft: 6, + flexGrow: 1, + }, + columnWrapper: { + justifyContent: 'flex-start', + }, + appCard: { + width: cardWidthPercent, + paddingLeft: 6, + paddingRight: 6, + marginBottom: 12, + }, + appCardInner: { + backgroundColor: colors.card, + borderRadius: 12, + overflow: 'hidden', + borderWidth: 1, + borderColor: colors.border, + }, + screenshotContainer: { + width: '100%', + aspectRatio: 1, + backgroundColor: colors.input, + }, + screenshot: { + width: '100%', + height: '100%', + }, + placeholderContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + placeholderText: { + color: colors.textSecondary, + fontSize: 14, + }, + appInfo: { + padding: 10, + }, + appName: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginBottom: 4, + }, + appDate: { + fontSize: 12, + color: colors.textSecondary, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + fontSize: 18, + fontWeight: '600', + color: colors.text, + marginBottom: 8, + }, + emptySubtext: { + fontSize: 14, + color: colors.textSecondary, + textAlign: 'center', + }, + // Menu overlay + menuOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + }, + // Rename modal styles + renameModalContent: { + position: 'absolute', + top: '35%', + left: 40, + right: 40, + backgroundColor: colors.card, + borderRadius: 14, + padding: 20, + }, + renameTitle: { + fontSize: 17, + fontWeight: '600', + color: colors.text, + textAlign: 'center', + marginBottom: 16, + }, + renameInput: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + padding: 12, + fontSize: 16, + color: colors.text, + backgroundColor: colors.inputBackground, + marginBottom: 16, + }, + renameButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + renameCancelButton: { + flex: 1, + padding: 12, + borderRadius: 8, + backgroundColor: colors.border, + marginRight: 8, + }, + renameCancelText: { + color: colors.text, + textAlign: 'center', + fontSize: 16, + fontWeight: '500', + }, + renameConfirmButton: { + flex: 1, + padding: 12, + borderRadius: 8, + backgroundColor: colors.primary, + marginLeft: 8, + }, + renameConfirmText: { + color: '#ffffff', + textAlign: 'center', + fontSize: 16, + fontWeight: '500', + }, + }); +}; + +export default AppGalleryScreen; diff --git a/react-native/src/app/AppViewerScreen.tsx b/react-native/src/app/AppViewerScreen.tsx new file mode 100644 index 00000000..1c69f461 --- /dev/null +++ b/react-native/src/app/AppViewerScreen.tsx @@ -0,0 +1,173 @@ +import React, { + useMemo, + useCallback, + useState, + useRef, + useEffect, +} from 'react'; +import { + StyleSheet, + View, + TouchableOpacity, + Text, + StatusBar, + Platform, + Animated, + NativeModules, +} from 'react-native'; +import { WebView } from 'react-native-webview'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { DrawerNavigationProp } from '@react-navigation/drawer'; +import { RouteParamList } from '../types/RouteTypes'; +import { HeaderLeftView } from '../prompt/HeaderLeftView'; +import { useTheme, ColorScheme } from '../theme'; +import { + injectErrorScript, + commonWebViewProps, +} from '../chat/component/markdown/htmlUtils'; + +const { NavigationBarModule } = NativeModules; + +type NavigationProp = DrawerNavigationProp; +type AppViewerRouteProp = RouteProp; + +function AppViewerScreen(): React.JSX.Element { + const navigation = useNavigation(); + const route = useRoute(); + const { app } = route.params; + const { colors, isDark } = useTheme(); + const [isFullScreen, setIsFullScreen] = useState(false); + const fadeAnim = useRef(new Animated.Value(0)).current; + const styles = createStyles(colors, isFullScreen); + + // Enable immersive mode only when entering fullscreen + useEffect(() => { + if (Platform.OS === 'android' && NavigationBarModule) { + if (isFullScreen) { + NavigationBarModule.setImmersiveMode(true); + } else { + NavigationBarModule.resetToDefault(); + } + } + }, [isFullScreen]); + + const handleLoadEnd = useCallback(() => { + Animated.timing(fadeAnim, { + toValue: 1, + duration: 100, + useNativeDriver: true, + }).start(); + }, [fadeAnim]); + + const headerLeft = useCallback( + () => HeaderLeftView(navigation, isDark), + [navigation, isDark] + ); + + const headerRight = useCallback( + () => ( + setIsFullScreen(true)}> + + + ), + [styles] + ); + + React.useLayoutEffect(() => { + navigation.setOptions({ + headerLeft, + headerRight, + headerShown: !isFullScreen, + }); + }, [navigation, headerLeft, headerRight, isFullScreen]); + + const htmlContent = useMemo( + () => injectErrorScript(app.htmlCode), + [app.htmlCode] + ); + + return ( + + {isFullScreen && ( + + )} + + + + {isFullScreen && ( + setIsFullScreen(false)}> + × + + )} + + ); +} + +const createStyles = (colors: ColorScheme, isFullScreen: boolean) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000000', + ...(isFullScreen && { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 9999, + }), + }, + webView: { + flex: 1, + backgroundColor: '#000000', + }, + fullScreenButton: { + padding: 2, + marginTop: 4, + }, + fullScreenIcon: { + fontSize: 24, + color: colors.text, + }, + closeButton: { + position: 'absolute', + top: Platform.OS === 'ios' ? 60 : (StatusBar.currentHeight || 20) + 20, + left: 20, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(50, 50, 50, 0.8)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + closeButtonX: { + fontSize: 20, + fontWeight: '400', + marginBottom: -2, + color: '#ffffff', + lineHeight: 20, + }, + }); + +export default AppViewerScreen; diff --git a/react-native/src/app/CreateAppScreen.tsx b/react-native/src/app/CreateAppScreen.tsx new file mode 100644 index 00000000..2cf65a38 --- /dev/null +++ b/react-native/src/app/CreateAppScreen.tsx @@ -0,0 +1,558 @@ +import React, { useCallback, useState, useRef, useMemo } from 'react'; +import { + SafeAreaView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + Image, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { DrawerNavigationProp } from '@react-navigation/drawer'; +import { RouteParamList } from '../types/RouteTypes'; +import { HeaderLeftView } from '../prompt/HeaderLeftView'; +import { useTheme, ColorScheme } from '../theme'; +import { WebView, WebViewMessageEvent } from 'react-native-webview'; +import RNFS from 'react-native-fs'; +import { saveApp, generateAppId } from '../storage/StorageUtils'; +import { SavedApp } from '../types/Chat'; +import { + injectErrorScript, + commonWebViewProps, +} from '../chat/component/markdown/htmlUtils'; +import { isMac } from '../App'; +import DocumentPicker from 'react-native-document-picker'; + +type NavigationProp = DrawerNavigationProp; + +const MAX_NAME_LENGTH = 20; +const APP_SCREENSHOTS_DIR = `${RNFS.DocumentDirectoryPath}/app`; +const MAX_SCREENSHOT_RETRIES = 3; + +// Check if content looks like HTML +const isHtmlContent = (content: string): boolean => { + const trimmed = content.trim().toLowerCase(); + return ( + (trimmed.startsWith('') || trimmed.includes('')) + ); +}; + +function CreateAppScreen(): React.JSX.Element { + const navigation = useNavigation(); + const { colors, isDark } = useTheme(); + const styles = createStyles(colors); + + const [appName, setAppName] = useState(''); + const [htmlCode, setHtmlCode] = useState(''); + const [showPreview, setShowPreview] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [hasError, setHasError] = useState(false); + const webViewRef = useRef(null); + const screenshotRetryCount = useRef(0); + + const headerLeft = useCallback( + () => HeaderLeftView(navigation, isDark), + [navigation, isDark] + ); + + React.useLayoutEffect(() => { + navigation.setOptions({ + headerLeft, + title: 'Create App', + }); + }, [navigation, headerLeft]); + + // Auto-detect HTML and switch to preview + const handleCodeChange = useCallback((text: string) => { + setHtmlCode(text); + const shouldShowPreview = isHtmlContent(text); + setShowPreview(shouldShowPreview); + if (shouldShowPreview) { + setHasError(false); + } + }, []); + + // Import file from document picker + const handleImportFile = useCallback(async () => { + try { + const result = await DocumentPicker.pick({ + type: [DocumentPicker.types.plainText, 'text/html', 'public.html'], + }); + const file = result[0]; + if (file.uri) { + const filePath = + Platform.OS === 'ios' + ? decodeURIComponent(file.uri.replace('file://', '')) + : file.uri; + const content = await RNFS.readFile(filePath, 'utf8'); + handleCodeChange(content); + + // Auto-fill app name from file name (without extension) + if (file.name) { + const nameWithoutExt = file.name.replace(/\.[^/.]+$/, ''); + setAppName(nameWithoutExt.slice(0, MAX_NAME_LENGTH)); + } + } + } catch (err) { + if (!DocumentPicker.isCancel(err)) { + console.error('Error picking file:', err); + Alert.alert('Error', 'Failed to read file'); + } + } + }, [handleCodeChange]); + + const htmlContent = useMemo( + () => (showPreview ? injectErrorScript(htmlCode) : ''), + [htmlCode, showPreview] + ); + + // Ensure app directory exists + const ensureAppDir = async () => { + const exists = await RNFS.exists(APP_SCREENSHOTS_DIR); + if (!exists) { + await RNFS.mkdir(APP_SCREENSHOTS_DIR); + } + }; + + // Capture screenshot using html2canvas + const captureScreenshot = useCallback(() => { + return ` + (function() { + if (typeof html2canvas === 'undefined') { + var script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'; + script.onload = function() { + captureNow(); + }; + script.onerror = function() { + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'screenshot_error', + message: 'Failed to load html2canvas' + })); + }; + document.head.appendChild(script); + } else { + captureNow(); + } + + function captureNow() { + var pixelRatio = window.devicePixelRatio || 1; + var captureWidth = Math.min(document.body.scrollWidth || window.innerWidth, 800); + var captureHeight = Math.min(document.body.scrollHeight || window.innerHeight, 800); + + html2canvas(document.body, { + backgroundColor: null, + useCORS: true, + allowTaint: true, + scale: Math.min(pixelRatio, 1), + width: captureWidth, + height: captureHeight, + windowWidth: captureWidth, + windowHeight: captureHeight, + x: 0, + y: 0, + scrollX: 0, + scrollY: 0 + }).then(function(canvas) { + var dataURL = canvas.toDataURL('image/jpeg', 0.9); + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'screenshot_success', + data: dataURL + })); + }).catch(function(error) { + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'screenshot_error', + message: error.message || 'Screenshot failed' + })); + }); + } + })(); + true; + `; + }, []); + + const handleScreenshotMessage = useCallback( + async (data: string) => { + try { + await ensureAppDir(); + + const appId = generateAppId(); + const base64Data = data.replace(/^data:image\/(png|jpeg);base64,/, ''); + const screenshotFileName = `${appId}.jpg`; + const screenshotFullPath = `${APP_SCREENSHOTS_DIR}/${screenshotFileName}`; + + await RNFS.writeFile(screenshotFullPath, base64Data, 'base64'); + + const storedPath = + Platform.OS === 'android' + ? `file://${screenshotFullPath}` + : `app/${screenshotFileName}`; + + const app: SavedApp = { + id: appId, + name: appName.trim(), + htmlCode: htmlCode, + screenshotPath: storedPath, + createdAt: Date.now(), + }; + + saveApp(app); + + setIsSaving(false); + Alert.alert('Success', `App "${appName}" created successfully!`, [ + { text: 'OK', onPress: () => navigation.goBack() }, + ]); + } catch (error) { + console.error('[CreateApp] Save error:', error); + setIsSaving(false); + Alert.alert('Error', 'Failed to save app'); + } + }, + [appName, htmlCode, navigation] + ); + + // Retry screenshot capture + const retryScreenshot = useCallback(() => { + if (screenshotRetryCount.current < MAX_SCREENSHOT_RETRIES) { + screenshotRetryCount.current += 1; + console.log( + `[CreateApp] Retrying screenshot (${screenshotRetryCount.current}/${MAX_SCREENSHOT_RETRIES})` + ); + setTimeout(() => { + if (webViewRef.current) { + webViewRef.current.injectJavaScript(captureScreenshot()); + } + }, 500); + return true; + } + return false; + }, [captureScreenshot]); + + const handleSaveWithoutScreenshot = useCallback(async () => { + try { + await ensureAppDir(); + + const appId = generateAppId(); + + const app: SavedApp = { + id: appId, + name: appName.trim(), + htmlCode: htmlCode, + screenshotPath: undefined, + createdAt: Date.now(), + }; + + saveApp(app); + + setIsSaving(false); + Alert.alert('Success', `App "${appName}" created (without preview)`, [ + { text: 'OK', onPress: () => navigation.goBack() }, + ]); + } catch (error) { + console.error('[CreateApp] Save error:', error); + setIsSaving(false); + Alert.alert('Error', 'Failed to save app'); + } + }, [appName, htmlCode, navigation]); + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + try { + const message = JSON.parse(event.nativeEvent.data); + + if (message.type === 'console_error') { + setHasError(true); + return; + } + + if (message.type === 'rendered' || message.type === 'update_rendered') { + setHasError(!message.success); + } + + if (message.type === 'screenshot_success') { + screenshotRetryCount.current = 0; + handleScreenshotMessage(message.data); + } + + if (message.type === 'screenshot_error') { + console.error('[CreateApp] Screenshot error:', message.message); + // Try to retry, if max retries reached, save without screenshot + if (!retryScreenshot()) { + handleSaveWithoutScreenshot(); + } + } + } catch (error) { + console.log('[CreateApp] Raw message:', event.nativeEvent.data); + } + }, + [handleScreenshotMessage, handleSaveWithoutScreenshot, retryScreenshot] + ); + + const handleCreate = useCallback(() => { + if (!appName.trim()) { + Alert.alert('Error', 'Please enter an app name'); + return; + } + if (appName.length > MAX_NAME_LENGTH) { + Alert.alert( + 'Error', + `App name must be ${MAX_NAME_LENGTH} characters or less` + ); + return; + } + if (!htmlCode.trim()) { + Alert.alert('Error', 'Please enter HTML code'); + return; + } + if (!isHtmlContent(htmlCode)) { + Alert.alert('Error', 'Please enter valid HTML code with tags'); + return; + } + + setIsSaving(true); + screenshotRetryCount.current = 0; + + // Capture screenshot + if (webViewRef.current) { + webViewRef.current.injectJavaScript(captureScreenshot()); + } else { + handleSaveWithoutScreenshot(); + } + }, [appName, htmlCode, captureScreenshot, handleSaveWithoutScreenshot]); + + return ( + + + + {/* Name Input */} + setAppName(text.slice(0, MAX_NAME_LENGTH))} + maxLength={MAX_NAME_LENGTH} + /> + + {/* HTML Code Input or Preview */} + {!showPreview ? ( + + + + + + + + + ) : ( + + + Preview + setShowPreview(false)} + style={styles.editButton}> + Edit + + + + setHasError(true)} + scrollEnabled={false} + /> + {hasError && ( + + Invalid HTML + + )} + + + )} + + {/* Create Button */} + + + {isSaving ? 'Creating...' : 'Create'} + + + + + + ); +} + +const createStyles = (colors: ColorScheme) => + StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: colors.background, + }, + keyboardAvoid: { + flex: 1, + }, + container: { + flex: 1, + padding: 16, + }, + input: { + borderWidth: 1, + borderColor: colors.promptScreenInputBorder, + borderRadius: 8, + padding: 12, + marginBottom: 16, + backgroundColor: colors.inputBackground, + color: colors.text, + fontSize: 16, + }, + codeInputContainer: { + flex: 1, + marginBottom: 16, + }, + importButton: { + position: 'absolute', + top: 8, + right: 8, + zIndex: 1, + padding: 6, + backgroundColor: colors.card, + borderRadius: 6, + borderWidth: 1, + borderColor: colors.border, + }, + importIcon: { + width: 18, + height: 18, + tintColor: colors.text, + }, + codeScrollView: { + flex: 1, + borderWidth: 1, + borderColor: colors.promptScreenInputBorder, + borderRadius: 8, + backgroundColor: colors.inputBackground, + }, + codeScrollContent: { + minWidth: '100%', + }, + codeInput: { + flex: 1, + padding: 12, + color: colors.text, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + fontSize: 14, + minWidth: 800, + }, + macInput: { + fontWeight: '300', + }, + previewContainer: { + flex: 1, + marginBottom: 16, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + overflow: 'hidden', + }, + previewHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + backgroundColor: colors.card, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + previewTitle: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + }, + editButton: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: colors.primary, + borderRadius: 6, + }, + editButtonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '500', + }, + webViewContainer: { + flex: 1, + backgroundColor: '#ffffff', + }, + webView: { + flex: 1, + backgroundColor: 'transparent', + }, + errorOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.input, + }, + errorText: { + fontSize: 14, + color: colors.text, + }, + createButton: { + backgroundColor: colors.promptScreenSaveButton, + padding: 16, + borderRadius: 8, + alignItems: 'center', + }, + buttonDisabled: { + opacity: 0.6, + }, + createButtonText: { + color: colors.promptScreenSaveButtonText, + fontSize: 16, + fontWeight: '500', + }, + }); + +export default CreateAppScreen; diff --git a/react-native/src/assets/app.png b/react-native/src/assets/app.png new file mode 100644 index 00000000..9dcc399e Binary files /dev/null and b/react-native/src/assets/app.png differ diff --git a/react-native/src/assets/app_dark.png b/react-native/src/assets/app_dark.png new file mode 100644 index 00000000..72c2f834 Binary files /dev/null and b/react-native/src/assets/app_dark.png differ diff --git a/react-native/src/assets/baidu.png b/react-native/src/assets/baidu.png new file mode 100644 index 00000000..77647f44 Binary files /dev/null and b/react-native/src/assets/baidu.png differ diff --git a/react-native/src/assets/baidu_dark.png b/react-native/src/assets/baidu_dark.png new file mode 100644 index 00000000..f730a76e Binary files /dev/null and b/react-native/src/assets/baidu_dark.png differ diff --git a/react-native/src/assets/bing.png b/react-native/src/assets/bing.png new file mode 100644 index 00000000..17a47e0c Binary files /dev/null and b/react-native/src/assets/bing.png differ diff --git a/react-native/src/assets/bing_dark.png b/react-native/src/assets/bing_dark.png new file mode 100644 index 00000000..11385422 Binary files /dev/null and b/react-native/src/assets/bing_dark.png differ diff --git a/react-native/src/assets/google.png b/react-native/src/assets/google.png new file mode 100644 index 00000000..694d0360 Binary files /dev/null and b/react-native/src/assets/google.png differ diff --git a/react-native/src/assets/google_dark.png b/react-native/src/assets/google_dark.png new file mode 100644 index 00000000..e251db3d Binary files /dev/null and b/react-native/src/assets/google_dark.png differ diff --git a/react-native/src/assets/link.png b/react-native/src/assets/link.png new file mode 100644 index 00000000..d9272e9f Binary files /dev/null and b/react-native/src/assets/link.png differ diff --git a/react-native/src/assets/tavily.png b/react-native/src/assets/tavily.png new file mode 100644 index 00000000..7b71bdd2 Binary files /dev/null and b/react-native/src/assets/tavily.png differ diff --git a/react-native/src/assets/tavily_dark.png b/react-native/src/assets/tavily_dark.png new file mode 100644 index 00000000..b97290e3 Binary files /dev/null and b/react-native/src/assets/tavily_dark.png differ diff --git a/react-native/src/assets/web_search.png b/react-native/src/assets/web_search.png new file mode 100644 index 00000000..804bd214 Binary files /dev/null and b/react-native/src/assets/web_search.png differ diff --git a/react-native/src/assets/web_search_grey.png b/react-native/src/assets/web_search_grey.png new file mode 100644 index 00000000..304009b8 Binary files /dev/null and b/react-native/src/assets/web_search_grey.png differ diff --git a/react-native/src/chat/ChatScreen.tsx b/react-native/src/chat/ChatScreen.tsx index a5720509..f4d0ac8a 100644 --- a/react-native/src/chat/ChatScreen.tsx +++ b/react-native/src/chat/ChatScreen.tsx @@ -13,6 +13,10 @@ import { StyleSheet, TextInput, } from 'react-native'; +import { + activateKeepAwake, + deactivateKeepAwake, +} from '@sayem314/react-native-keep-awake'; import { voiceChatService } from './service/VoiceChatService'; import AudioWaveformComponent, { AudioWaveformRef, @@ -74,6 +78,8 @@ import { import HeaderTitle from './component/HeaderTitle.tsx'; import { showInfo } from './util/ToastUtils.ts'; import { HeaderOptions } from '@react-navigation/elements'; +import { webSearchOrchestrator } from '../websearch/services/WebSearchOrchestrator.ts'; +import { Citation } from '../types/Chat.ts'; const BOT_ID = 2; @@ -106,7 +112,7 @@ function ChatScreen(): React.JSX.Element { const mode = route.params?.mode ?? currentMode; const modeRef = useRef(mode); const isNovaSonic = - getTextModel().modelId.includes('nova-sonic') && + getTextModel().modelId.includes('sonic') && modeRef.current === ChatMode.Text; const [messages, setMessages] = useState([]); @@ -114,7 +120,6 @@ function ChatScreen(): React.JSX.Element { const [systemPrompt, setSystemPrompt] = useState( isNovaSonic ? getCurrentVoiceSystemPrompt : getCurrentSystemPrompt ); - const [showSystemPrompt, setShowSystemPrompt] = useState(true); const [screenDimensions, setScreenDimensions] = useState( Dimensions.get('window') ); @@ -143,6 +148,7 @@ function ChatScreen(): React.JSX.Element { const containerHeightRef = useRef(0); const [isShowVoiceLoading, setIsShowVoiceLoading] = useState(false); const audioWaveformRef = useRef(null); + const [searchPhase, setSearchPhase] = useState(''); const endVoiceConversationRef = useRef<(() => Promise) | null>(null); const currentScrollOffsetRef = useRef(0); @@ -173,6 +179,18 @@ function ChatScreen(): React.JSX.Element { usageRef.current = usage; }, [chatStatus, messages, usage]); + // Keep screen awake during streaming output + useEffect(() => { + if (chatStatus === ChatStatus.Running) { + activateKeepAwake(); + } else { + deactivateKeepAwake(); + } + return () => { + deactivateKeepAwake(); + }; + }, [chatStatus]); + useEffect(() => { drawerTypeRef.current = drawerType; }, [drawerType]); @@ -191,7 +209,7 @@ function ChatScreen(): React.JSX.Element { }, // Handle error message => { - if (getTextModel().modelId.includes('nova-sonic')) { + if (getTextModel().modelId.includes('sonic')) { handleVoiceChatTranscript('ASSISTANT', message); endVoiceConversationRef.current?.(); saveCurrentMessages(); @@ -218,7 +236,6 @@ function ChatScreen(): React.JSX.Element { setMessages([]); bedrockMessages.current = []; - setShowSystemPrompt(true); showKeyboard(); }, []) ); @@ -240,8 +257,6 @@ function ChatScreen(): React.JSX.Element { } usage={usage} onDoubleTap={scrollToTop} - onShowSystemPrompt={() => setShowSystemPrompt(true)} - isShowSystemPrompt={showSystemPrompt} /> ), // eslint-disable-next-line react/no-unstable-nested-components @@ -268,7 +283,7 @@ function ChatScreen(): React.JSX.Element { ), }; navigation.setOptions(headerOptions); - }, [usage, navigation, mode, systemPrompt, showSystemPrompt, isDark]); + }, [usage, navigation, mode, systemPrompt, isDark]); // sessionId changes (start new chat or click another session) useEffect(() => { @@ -287,9 +302,6 @@ function ChatScreen(): React.JSX.Element { } saveCurrentMessages(); } - if (modeRef.current === ChatMode.Image) { - setShowSystemPrompt(true); - } if (modeRef.current !== mode) { // when change chat mode, clear system prompt and files modeRef.current = mode; @@ -355,9 +367,16 @@ function ChatScreen(): React.JSX.Element { // keyboard show listener for scroll to bottom useEffect(() => { + const handleKeyboardShow = () => { + // Only scroll to bottom if the chat input is focused + if (textInputViewRef.current?.isFocused()) { + scrollToBottom(); + } + }; + const keyboardDidShowListener = Platform.select({ - ios: Keyboard.addListener('keyboardWillShow', scrollToBottom), - android: Keyboard.addListener('keyboardDidShow', scrollToBottom), + ios: Keyboard.addListener('keyboardWillShow', handleKeyboardShow), + android: Keyboard.addListener('keyboardDidShow', handleKeyboardShow), }); return () => { @@ -559,114 +578,163 @@ function ChatScreen(): React.JSX.Element { if (modeRef.current === ChatMode.Image) { sendEventRef.current('onImageStart'); } - controllerRef.current = new AbortController(); - isCanceled.current = false; - const startRequestTime = new Date().getTime(); - let latencyMs = 0; - let metrics: Metrics | undefined; - invokeBedrockWithCallBack( - bedrockMessages.current, - modeRef.current, - systemPromptRef.current, - () => isCanceled.current, - controllerRef.current, - ( - msg: string, - complete: boolean, - needStop: boolean, - usageInfo?: Usage, - reasoning?: string - ) => { - if (chatStatusRef.current !== ChatStatus.Running) { - return; - } - if (latencyMs === 0) { - latencyMs = new Date().getTime() - startRequestTime; + + // Wrap in async function to support await + (async () => { + // Create AbortController before web search so it can be used throughout + controllerRef.current = new AbortController(); + isCanceled.current = false; + + // Get the last user message (the one after bot message) + const userMessage = messages.length > 1 ? messages[1]?.text : null; + + let webSearchSystemPrompt; + let webSearchCitations: Citation[] | undefined; + // Execute web search only in text mode with user message + if (userMessage && modeRef.current === ChatMode.Text) { + try { + const webSearchResult = await webSearchOrchestrator.execute( + userMessage, + bedrockMessages.current, + (phase: string) => { + setSearchPhase(phase); + }, + undefined, + controllerRef.current + ); + if (webSearchResult) { + webSearchSystemPrompt = webSearchResult.systemPrompt; + webSearchCitations = webSearchResult.citations; + } + } catch (error) { + // For errors, log and continue without web search + console.log('❌ Web search error in ChatScreen:', error); } - const updateMessage = () => { - if (usageInfo) { - setUsage(prevUsage => ({ - modelName: usageInfo.modelName, - inputTokens: - (prevUsage?.inputTokens || 0) + usageInfo.inputTokens, - outputTokens: - (prevUsage?.outputTokens || 0) + usageInfo.outputTokens, - totalTokens: - (prevUsage?.totalTokens || 0) + usageInfo.totalTokens, - })); - updateTotalUsage(usageInfo); - const renderSec = - (new Date().getTime() - startRequestTime - latencyMs) / 1000; - const speed = usageInfo.outputTokens / renderSec; - if (!metrics && modeRef.current === ChatMode.Text) { - metrics = { - latencyMs: (latencyMs / 1000).toFixed(2), - speed: speed.toFixed(speed > 100 ? 1 : 2), - }; - } + } + + // Check if aborted after web search completes + if (isCanceled.current) { + console.log('⚠️ Operation aborted, stopping'); + setChatStatus(ChatStatus.Init); + setSearchPhase(''); + return; + } + + // Clear searchPhase before starting AI response + setSearchPhase(''); + const startRequestTime = new Date().getTime(); + let latencyMs = 0; + let metrics: Metrics | undefined; + + // Prioritize web search system prompt, otherwise use user-selected system prompt + const effectiveSystemPrompt = + webSearchSystemPrompt || systemPromptRef.current; + + invokeBedrockWithCallBack( + bedrockMessages.current, + modeRef.current, + effectiveSystemPrompt, + () => isCanceled.current, + controllerRef.current, + ( + msg: string, + complete: boolean, + needStop: boolean, + usageInfo?: Usage, + reasoning?: string + ) => { + if (chatStatusRef.current !== ChatStatus.Running) { + return; } - const previousMessage = messagesRef.current[0]; - if ( - previousMessage.text !== msg || - previousMessage.reasoning !== reasoning || - (!previousMessage.metrics && metrics) - ) { - setMessages(prevMessages => { - const newMessages = [...prevMessages]; - newMessages[0] = { - ...prevMessages[0], - text: - isCanceled.current && - (previousMessage.text === textPlaceholder || - previousMessage.text === '') - ? 'Canceled...' - : msg, - reasoning: reasoning, - metrics: metrics, - }; - return newMessages; - }); + if (latencyMs === 0) { + latencyMs = new Date().getTime() - startRequestTime; } - }; - const setComplete = () => { - trigger(HapticFeedbackTypes.notificationSuccess); - setChatStatus(ChatStatus.Complete); - }; - if (modeRef.current === ChatMode.Text) { - trigger(HapticFeedbackTypes.selection); - updateMessage(); - if (complete) { - setComplete(); + const updateMessage = () => { + if (usageInfo) { + setUsage(prevUsage => ({ + modelName: usageInfo.modelName, + inputTokens: + (prevUsage?.inputTokens || 0) + usageInfo.inputTokens, + outputTokens: + (prevUsage?.outputTokens || 0) + usageInfo.outputTokens, + totalTokens: + (prevUsage?.totalTokens || 0) + usageInfo.totalTokens, + })); + updateTotalUsage(usageInfo); + const renderSec = + (new Date().getTime() - startRequestTime - latencyMs) / 1000; + const speed = usageInfo.outputTokens / renderSec; + if (!metrics && modeRef.current === ChatMode.Text) { + metrics = { + latencyMs: (latencyMs / 1000).toFixed(2), + speed: speed.toFixed(speed > 100 ? 1 : 2), + }; + } + } + const previousMessage = messagesRef.current[0]; + if ( + previousMessage.text !== msg || + previousMessage.reasoning !== reasoning || + (!previousMessage.metrics && metrics) + ) { + setMessages(prevMessages => { + const newMessages = [...prevMessages]; + newMessages[0] = { + ...prevMessages[0], + text: + isCanceled.current && + (previousMessage.text === textPlaceholder || + previousMessage.text === '') + ? 'Canceled...' + : msg, + reasoning: reasoning, + metrics: metrics, + citations: webSearchCitations, + }; + return newMessages; + }); + } + }; + const setComplete = () => { + trigger(HapticFeedbackTypes.notificationSuccess); + setChatStatus(ChatStatus.Complete); + }; + if (modeRef.current === ChatMode.Text) { + trigger(HapticFeedbackTypes.selection); + updateMessage(); + if (complete) { + setComplete(); + } + } else { + if (needStop) { + sendEventRef.current('onImageStop'); + } else { + sendEventRef.current('onImageComplete'); + } + setTimeout(() => { + updateMessage(); + setComplete(); + }, 1000); } - } else { if (needStop) { - sendEventRef.current('onImageStop'); - } else { - sendEventRef.current('onImageComplete'); + isCanceled.current = true; } - setTimeout(() => { - updateMessage(); - setComplete(); - }, 1000); } - if (needStop) { - isCanceled.current = true; - } - } - ).then(); + ).then(); + })(); // Close async IIFE } }, [messages]); // handle onSend - const onSend = useCallback((message: SwiftChatMessage[] = []) => { + const onSend = useCallback(async (message: SwiftChatMessage[] = []) => { // Reset user scroll state when sending a new message setUserScrolled(false); - setShowSystemPrompt(modeRef.current === ChatMode.Image); const files = selectedFilesRef.current; if (!isAllFileReady(files)) { showInfo('please wait for all videos to be ready'); return; } + if (message[0]?.text || files.length > 0) { if (!message[0]?.text) { if (modeRef.current === ChatMode.Text) { @@ -758,7 +826,7 @@ function ChatScreen(): React.JSX.Element { } }; - const styles = createStyles(colors); + const styles = createStyles(colors, isNovaSonic); return ( @@ -770,8 +838,8 @@ function ChatScreen(): React.JSX.Element { Platform.OS === 'android' ? 0 : screenHeight > screenWidth && screenWidth < 500 - ? 32 // iphone in portrait - : 20 + ? 24 // iphone in portrait + : 12 } messages={messages} onSend={onSend} @@ -883,7 +951,6 @@ function ChatScreen(): React.JSX.Element { endVoiceConversationRef.current?.(); }} chatMode={modeRef.current} - isShowSystemPrompt={showSystemPrompt} hasInputText={hasInputText} chatStatus={chatStatus} systemPrompt={systemPrompt} @@ -895,14 +962,16 @@ function ChatScreen(): React.JSX.Element { msg => msg._id === props.currentMessage?._id ); + const isLastAIMessage = + props.currentMessage?._id === messages[0]?._id && + props.currentMessage?.user._id !== 1; + return ( { scrollUpByHeight(expanded, height, animated); }} @@ -956,10 +1025,8 @@ function ChatScreen(): React.JSX.Element { renderInputToolbar={props => ( )} textInputProps={{ @@ -967,6 +1034,8 @@ function ChatScreen(): React.JSX.Element { ...{ fontWeight: isMac ? '300' : 'normal', color: colors.text, + smartInsertDelete: false, + spellCheck: false, blurOnSubmit: isMac, onSubmitEditing: () => { if ( @@ -1020,7 +1089,7 @@ function ChatScreen(): React.JSX.Element { ); } -const createStyles = (colors: ColorScheme) => +const createStyles = (colors: ColorScheme, isNovaSonic: boolean) => StyleSheet.create({ container: { flex: 1, @@ -1033,13 +1102,25 @@ const createStyles = (colors: ColorScheme) => justifyContent: 'flex-end', }, textInputStyle: { - marginLeft: 14, + marginLeft: 10, lineHeight: 22, }, composerTextInput: { - backgroundColor: colors.background, + backgroundColor: 'transparent', color: colors.text, }, + inputToolbarContainer: { + backgroundColor: colors.background, + borderTopWidth: 0, + paddingHorizontal: 10, + paddingTop: 0, + paddingBottom: isMac ? 10 : Platform.OS === 'android' ? 8 : 2, + }, + inputToolbarPrimary: { + backgroundColor: isNovaSonic ? 'transparent' : colors.chatInputBackground, + borderRadius: 12, + paddingHorizontal: 0, + }, }); export default ChatScreen; diff --git a/react-native/src/chat/component/CitationBadge.tsx b/react-native/src/chat/component/CitationBadge.tsx new file mode 100644 index 00000000..6e7f2e2d --- /dev/null +++ b/react-native/src/chat/component/CitationBadge.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +import { TouchableOpacity, Text, StyleSheet, Linking } from 'react-native'; +import { isAndroid } from '../../utils/PlatformUtils'; +import { useTheme, ColorScheme } from '../../theme'; +import { trigger } from '../util/HapticUtils'; +import { HapticFeedbackTypes } from 'react-native-haptic-feedback/src/types'; + +interface CitationBadgeProps { + number: number; + url: string; +} + +const CitationBadge: React.FC = ({ number, url }) => { + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const handlePress = async () => { + trigger(HapticFeedbackTypes.impactLight); + try { + const supported = await Linking.canOpenURL(url); + if (supported) { + await Linking.openURL(url); + } else { + console.log(`Cannot open URL: ${url}`); + } + } catch (error) { + console.error('Error opening URL:', error); + } + }; + + return ( + + {number} + + ); +}; + +const createStyles = (colors: ColorScheme) => + StyleSheet.create({ + badge: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: colors.citationBadgeBackground, + justifyContent: 'center', + alignItems: 'center', + transform: isAndroid ? [{ translateY: 3 }] : [], + }, + badgeText: { + fontSize: 10, + fontWeight: '600', + color: colors.citationBadgeText, + }, + }); + +export default CitationBadge; diff --git a/react-native/src/chat/component/CitationList.tsx b/react-native/src/chat/component/CitationList.tsx new file mode 100644 index 00000000..7b106b6f --- /dev/null +++ b/react-native/src/chat/component/CitationList.tsx @@ -0,0 +1,124 @@ +import React, { useMemo, useState } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Image } from 'react-native'; +import { Citation } from '../../types/Chat'; +import { useTheme, ColorScheme } from '../../theme'; +import { trigger } from '../util/HapticUtils'; +import { HapticFeedbackTypes } from 'react-native-haptic-feedback/src/types'; +import CitationModal from './CitationModal'; +import { getFaviconUrl } from '../util/FaviconUtils'; + +interface CitationListProps { + citations: Citation[]; +} + +const CitationList: React.FC = ({ citations }) => { + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const [modalVisible, setModalVisible] = useState(false); + + // Memoize favicon URLs to prevent re-fetching on every render + const faviconUrls = useMemo(() => { + return citations.slice(0, 4).map(citation => getFaviconUrl(citation.url)); + }, [citations]); + + if (!citations || citations.length === 0) { + return null; + } + + const handleOpenModal = () => { + trigger(HapticFeedbackTypes.impactLight); + setModalVisible(true); + }; + + const handleCloseModal = () => { + setModalVisible(false); + }; + + return ( + <> + + + {faviconUrls.map((faviconUrl, index) => ( + 0 && styles.faviconContainerOverlap, + ]}> + {faviconUrl ? ( + + ) : ( + + )} + + ))} + + {citations.length} website{citations.length !== 1 ? 's' : ''} + + + + + + + ); +}; + +const createStyles = (colors: ColorScheme) => + StyleSheet.create({ + container: { + marginTop: 8, + marginBottom: 4, + paddingVertical: 8, + paddingHorizontal: 12, + backgroundColor: colors.citationListBackground, + borderRadius: 20, + alignSelf: 'flex-start', + }, + iconsRow: { + flexDirection: 'row', + alignItems: 'center', + }, + faviconContainer: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: colors.background, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: colors.citationListBackground, + }, + faviconContainerOverlap: { + marginLeft: -8, + }, + faviconImage: { + width: 20, + height: 20, + borderRadius: 10, + }, + faviconPlaceholder: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: colors.citationBorder, + }, + sourceCount: { + fontSize: 13, + fontWeight: '500', + color: colors.text, + marginLeft: 8, + }, + }); + +export default CitationList; diff --git a/react-native/src/chat/component/CitationModal.tsx b/react-native/src/chat/component/CitationModal.tsx new file mode 100644 index 00000000..76fc76f4 --- /dev/null +++ b/react-native/src/chat/component/CitationModal.tsx @@ -0,0 +1,299 @@ +import React, { useMemo, useEffect, useRef } from 'react'; +import { + Modal, + View, + Text, + TouchableOpacity, + StyleSheet, + ScrollView, + Linking, + Animated, + Image, +} from 'react-native'; +import { Citation } from '../../types/Chat'; +import { useTheme, ColorScheme } from '../../theme'; +import { trigger } from '../util/HapticUtils'; +import { HapticFeedbackTypes } from 'react-native-haptic-feedback/src/types'; +import { getFaviconUrl } from '../util/FaviconUtils'; + +interface CitationModalProps { + visible: boolean; + citations: Citation[]; + onClose: () => void; +} + +const CitationModal: React.FC = ({ + visible, + citations, + onClose, +}) => { + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const slideAnim = useRef(new Animated.Value(300)).current; + const fadeAnim = useRef(new Animated.Value(0)).current; + const [modalVisible, setModalVisible] = React.useState(visible); + + // Memoize favicon URLs to prevent re-fetching on every render + const citationsWithFavicons = useMemo(() => { + return citations.map(citation => ({ + ...citation, + faviconUrl: getFaviconUrl(citation.url), + })); + }, [citations]); + + useEffect(() => { + if (visible) { + // Show modal immediately + setModalVisible(true); + + // Reset to initial position first + slideAnim.setValue(300); + fadeAnim.setValue(0); + + // Show overlay immediately and slide content up + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(slideAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + ]).start(); + } else if (modalVisible) { + // Fade out and slide down + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(slideAnim, { + toValue: 300, + duration: 250, + useNativeDriver: true, + }), + ]).start(() => { + // Hide modal after animation completes + setModalVisible(false); + }); + } + }, [visible, fadeAnim, slideAnim, modalVisible]); + + const handleOpenUrl = (url: string) => { + trigger(HapticFeedbackTypes.impactLight); + Linking.openURL(url).catch(err => { + console.log('Failed to open URL:', err); + }); + }; + + return ( + + + + + + References + + + + + + + {citationsWithFavicons.length === 0 && ( + No citations available + )} + {citationsWithFavicons.map((citation, index) => ( + handleOpenUrl(citation.url)}> + + + {citation.faviconUrl ? ( + + ) : ( + + {citation.number} + + )} + + + + + {citation.title} + + + + {citation.number} + + + + {citation.excerpt && ( + + {citation.excerpt} + + )} + + {citation.url} + + + + + ))} + + + + + ); +}; + +const createStyles = (colors: ColorScheme) => + StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: colors.overlay, + justifyContent: 'flex-end', + }, + overlayTouchable: { + flex: 1, + }, + modalContainer: { + backgroundColor: colors.background, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: '80%', + width: '100%', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.text, + }, + closeButton: { + padding: 4, + }, + closeText: { + fontSize: 20, + color: colors.textSecondary, + fontWeight: '400', + }, + scrollView: { + maxHeight: 500, + }, + emptyText: { + padding: 20, + textAlign: 'center', + color: colors.textSecondary, + fontSize: 14, + }, + citationItem: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.citationBorder, + }, + citationHeader: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + citationNumberBadge: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: colors.citationBackground, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + flexShrink: 0, + }, + citationNumberText: { + fontSize: 12, + fontWeight: '600', + color: colors.citationText, + }, + faviconImage: { + width: 20, + height: 20, + borderRadius: 10, + }, + citationContent: { + flex: 1, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 4, + }, + faviconPlaceholder: { + fontSize: 14, + marginRight: 6, + }, + citationTitle: { + flex: 1, + fontSize: 15, + fontWeight: '500', + color: colors.text, + lineHeight: 20, + marginRight: 8, + }, + citationNumberCircle: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: colors.textSecondary, + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + marginTop: 1, + }, + citationNumberInTitle: { + fontSize: 11, + fontWeight: '600', + color: colors.background, + }, + citationExcerpt: { + fontSize: 13, + color: colors.textSecondary, + lineHeight: 18, + marginTop: 6, + marginBottom: 6, + }, + citationUrl: { + fontSize: 12, + color: colors.textTertiary, + marginTop: 4, + }, + }); + +export default CitationModal; diff --git a/react-native/src/chat/component/CustomAddFileComponent.tsx b/react-native/src/chat/component/CustomAddFileComponent.tsx index 1269b0b6..0805dcf2 100644 --- a/react-native/src/chat/component/CustomAddFileComponent.tsx +++ b/react-native/src/chat/component/CustomAddFileComponent.tsx @@ -199,7 +199,7 @@ export const CustomAddFileComponent: React.FC = ({ onFileSelected(files); } } catch (error) { - console.error('Error handling paste files:', error); + console.log('Error handling paste files:', error); showInfo('Error processing pasted files'); } }, [processFiles, onFileSelected]); @@ -391,7 +391,7 @@ const getFileNameWithoutExtension = (fileName: string) => { export const isVideoSupported = (): boolean => { const textModelId = getTextModel().modelId; - return textModelId.includes('nova-pro') || textModelId.includes('nova-lite'); + return textModelId.includes('nova'); }; const getFiles = async (res: ImagePickerResponse) => { @@ -455,7 +455,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', marginBottom: 0, - marginRight: 6, + marginRight: 0, marginLeft: 10, }, listContainerStyle: { diff --git a/react-native/src/chat/component/CustomChatFooter.tsx b/react-native/src/chat/component/CustomChatFooter.tsx index 00046e29..006d709a 100644 --- a/react-native/src/chat/component/CustomChatFooter.tsx +++ b/react-native/src/chat/component/CustomChatFooter.tsx @@ -13,8 +13,10 @@ import { import { PromptListComponent } from './PromptListComponent.tsx'; import { ModelIconButton } from './ModelIconButton.tsx'; import { ModelSelectionModal } from './ModelSelectionModal.tsx'; +import { WebSearchIconButton } from './WebSearchIconButton.tsx'; +import { WebSearchSelectionModal } from './WebSearchSelectionModal.tsx'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { isAndroid } from '../../utils/PlatformUtils.ts'; +import { isAndroid, isMacCatalyst } from '../../utils/PlatformUtils.ts'; interface CustomComposerProps { files: FileInfo[]; @@ -22,7 +24,6 @@ interface CustomComposerProps { onSystemPromptUpdated: (prompt: SystemPrompt | null) => void; onSwitchedToTextModel: () => void; chatMode: ChatMode; - isShowSystemPrompt: boolean; hasInputText?: boolean; chatStatus?: ChatStatus; systemPrompt?: SystemPrompt | null; @@ -34,15 +35,18 @@ export const CustomChatFooter: React.FC = ({ onSystemPromptUpdated, onSwitchedToTextModel, chatMode, - isShowSystemPrompt, hasInputText = false, chatStatus, systemPrompt, }) => { const [modalVisible, setModalVisible] = useState(false); + const [searchModalVisible, setSearchModalVisible] = useState(false); const [iconPosition, setIconPosition] = useState({ x: 0, y: 0 }); + const [searchIconPosition, setSearchIconPosition] = useState({ x: 0, y: 0 }); const modelIconRef = useRef(null); + const searchIconRef = useRef(null); const iconPositionRef = useRef({ x: 0, y: 0 }); + const searchIconPositionRef = useRef({ x: 0, y: 0 }); const insets = useSafeAreaInsets(); const statusBarHeight = useRef(insets.top); const isVirtualTryOn = systemPrompt?.id === -7; @@ -65,6 +69,22 @@ export const CustomChatFooter: React.FC = ({ setModalVisible(true); } }; + + const handleOpenSearchModal = () => { + if (searchIconPositionRef.current.y === 0) { + searchIconRef.current?.measure((x, y, width, height, pageX, pageY) => { + searchIconPositionRef.current = { + x: pageX, + y: pageY + 10 + (isAndroid ? statusBarHeight.current : 0), + }; + setSearchIconPosition(searchIconPositionRef.current); + setSearchModalVisible(true); + }); + } else { + setSearchModalVisible(true); + } + }; + useEffect(() => { Keyboard.addListener('keyboardWillShow', () => { modelIconRef.current?.measure((x, y, width, height, pageX, pageY) => { @@ -76,12 +96,25 @@ export const CustomChatFooter: React.FC = ({ setIconPosition(iconPositionRef.current); } }); + searchIconRef.current?.measure((x, y, width, height, pageX, pageY) => { + if (searchIconPositionRef.current.y === 0) { + searchIconPositionRef.current = { + x: pageX, + y: pageY + 10 + (isAndroid ? statusBarHeight.current : 0), + }; + setSearchIconPosition(searchIconPositionRef.current); + } + }); }); }, []); const handleCloseModal = () => { setModalVisible(false); }; + + const handleCloseSearchModal = () => { + setSearchModalVisible(false); + }; const isHideFileList = hasInputText || chatStatus === ChatStatus.Running; return ( @@ -89,22 +122,15 @@ export const CustomChatFooter: React.FC = ({ 0 && { - height: 136, - }), - ...(!isShowSystemPrompt && - files.length > 0 && { - height: 90, - }), - ...(isShowSystemPrompt && - files.length === 0 && { - height: 60, - }), - ...(!isShowSystemPrompt && - files.length === 0 && { - height: 0, - }), + ...(files.length > 0 && { + height: 136, + }), + ...(files.length === 0 && { + height: 60, + }), + ...(isMacCatalyst && { + paddingBottom: 18, + }), }}> {(isHideFileList || files.length > 0) && ( = ({ isHideFileList={isHideFileList} /> )} - {((isShowSystemPrompt && chatMode === ChatMode.Text) || - chatMode === ChatMode.Image) && ( + {(chatMode === ChatMode.Text || chatMode === ChatMode.Image) && ( 0 && { - marginTop: -72, - }), + ...(files.length > 0 && { + marginTop: -72, + }), }}> { @@ -134,9 +158,14 @@ export const CustomChatFooter: React.FC = ({ chatMode={chatMode} /> {chatMode === ChatMode.Text && ( - - - + <> + + + + + + + )} )} @@ -146,6 +175,11 @@ export const CustomChatFooter: React.FC = ({ onClose={handleCloseModal} iconPosition={iconPosition} /> + ); }; @@ -158,5 +192,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', flex: 1, + marginBottom: isAndroid ? 12 : 0, }, }); diff --git a/react-native/src/chat/component/CustomMessageComponent.tsx b/react-native/src/chat/component/CustomMessageComponent.tsx index 7c0de648..fd59db0e 100644 --- a/react-native/src/chat/component/CustomMessageComponent.tsx +++ b/react-native/src/chat/component/CustomMessageComponent.tsx @@ -43,10 +43,12 @@ import { getReasoningExpanded, saveReasoningExpanded, } from '../../storage/StorageUtils.ts'; +import CitationList from './CitationList'; interface CustomMessageProps extends MessageProps { chatStatus: ChatStatus; isLastAIMessage?: boolean; + searchPhase?: string; onRegenerate?: () => void; onReasoningToggle?: ( expanded: boolean, @@ -61,6 +63,7 @@ const CustomMessageComponent: React.FC = ({ currentMessage, chatStatus, isLastAIMessage, + searchPhase, onRegenerate, onReasoningToggle, }) => { @@ -86,6 +89,8 @@ const CustomMessageComponent: React.FC = ({ (currentMessage?.text === '...' || currentMessage?.text === ''); const [forceShowButtons, setForceShowButtons] = useState(false); const isUser = useRef(currentMessage?.user?._id === 1); + // Force re-render key for Android citation badge layout fix + const [citationRenderKey, setCitationRenderKey] = useState(0); const { drawerType } = useAppContext(); const chatScreenWidth = isMac && drawerType === 'permanent' ? screenWidth - 300 : screenWidth; @@ -199,8 +204,21 @@ const CustomMessageComponent: React.FC = ({ }, []); const customMarkdownRenderer = useMemo( - () => new CustomMarkdownRenderer(handleImagePress, colors, isDark), - [handleImagePress, colors, isDark] + () => + new CustomMarkdownRenderer( + handleImagePress, + colors, + isDark, + currentMessage?.citations || [], + onReasoningToggle + ), + [ + handleImagePress, + colors, + isDark, + currentMessage?.citations, + onReasoningToggle, + ] ); const customTokenizer = useMemo(() => new CustomTokenizer(), []); @@ -370,6 +388,25 @@ const CustomMessageComponent: React.FC = ({ } }, [handleReasoningCopy, reasoningCopied]); + // Android: Force re-render citation badges after streaming completes + // to fix inline layout issues that occur during streaming + useEffect(() => { + if ( + isAndroid && + chatStatus !== ChatStatus.Running && + chatStatusRef.current === ChatStatus.Running && + currentMessage?.citations && + currentMessage.citations.length > 0 + ) { + // Delay slightly to ensure the streaming has fully stopped + const timer = setTimeout(() => { + setCitationRenderKey(prev => prev + 1); + }, 100); + return () => clearTimeout(timer); + } + chatStatusRef.current = chatStatus; + }, [chatStatus, currentMessage?.citations]); + const messageContent = useMemo(() => { if (!currentMessage) { return null; @@ -378,6 +415,7 @@ const CustomMessageComponent: React.FC = ({ if (!isUser.current) { return ( = ({ customTokenizer, chatScreenWidth, styles.questionText, + citationRenderKey, ]); const messageActionButtons = useMemo(() => { @@ -499,12 +538,15 @@ const CustomMessageComponent: React.FC = ({ {hasReasoning && reasoningSection} {showLoading && ( - + + {searchPhase && ( + {searchPhase} + )} )} {!isLoading && !isEdit && ( @@ -550,6 +592,11 @@ const CustomMessageComponent: React.FC = ({ {currentMessage.text} )} + {!isUser.current && + chatStatus !== ChatStatus.Running && + currentMessage.citations && ( + + )} {((isLastAIMessage && chatStatus !== ChatStatus.Running) || forceShowButtons) && messageActionButtons} @@ -656,10 +703,17 @@ const createStyles = (colors: ColorScheme) => paddingHorizontal: 8, paddingVertical: 4, }, - loading: { + loadingContainer: { + flexDirection: 'row', + alignItems: 'center', marginTop: 12, marginBottom: 10, }, + searchPhaseText: { + marginLeft: 8, + fontSize: 14, + color: colors.textTertiary, + }, actionButtonsContainer: { flexDirection: 'row', alignItems: 'center', @@ -709,6 +763,7 @@ export default React.memo(CustomMessageComponent, (prevProps, nextProps) => { nextProps.currentMessage?.reasoning && prevProps.chatStatus === nextProps.chatStatus && prevProps.isLastAIMessage === nextProps.isLastAIMessage && + prevProps.searchPhase === nextProps.searchPhase && prevProps.onRegenerate === nextProps.onRegenerate && prevProps.onReasoningToggle === nextProps.onReasoningToggle ); diff --git a/react-native/src/chat/component/CustomSendComponent.tsx b/react-native/src/chat/component/CustomSendComponent.tsx index ddeae042..86441289 100644 --- a/react-native/src/chat/component/CustomSendComponent.tsx +++ b/react-native/src/chat/component/CustomSendComponent.tsx @@ -38,7 +38,7 @@ const CustomSendComponent: React.FC = ({ const { text } = props; const { colors, isDark } = useTheme(); const styles = createStyles(colors); - const isNovaSonic = getTextModel().modelId.includes('nova-sonic'); + const isNovaSonic = getTextModel().modelId.includes('sonic'); const isVirtualTryOn = systemPrompt?.id === -7; let isShowSending = false; if (chatMode === ChatMode.Image) { @@ -160,7 +160,7 @@ const isModelSupportUploadImages = (chatMode: ChatMode): boolean => { const createStyles = (colors: ColorScheme) => StyleSheet.create({ stopContainer: { - marginRight: 15, + marginRight: 10, marginLeft: 10, width: 26, height: 26, @@ -195,7 +195,7 @@ const createStyles = (colors: ColorScheme) => loadingContainer: { justifyContent: 'center', alignItems: 'center', - marginRight: 15, + marginRight: 10, marginLeft: 10, height: 44, }, @@ -203,7 +203,7 @@ const createStyles = (colors: ColorScheme) => width: 26, height: 26, borderRadius: 15, - marginRight: 15, + marginRight: 10, marginLeft: 10, }, }); diff --git a/react-native/src/chat/component/HeaderTitle.tsx b/react-native/src/chat/component/HeaderTitle.tsx index 1e3cbcc4..40234af3 100644 --- a/react-native/src/chat/component/HeaderTitle.tsx +++ b/react-native/src/chat/component/HeaderTitle.tsx @@ -8,16 +8,12 @@ interface HeaderTitleProps { title: string; usage?: Usage; onDoubleTap: () => void; - onShowSystemPrompt: () => void; - isShowSystemPrompt: boolean; } const HeaderTitle: React.FC = ({ title, usage, onDoubleTap, - onShowSystemPrompt, - isShowSystemPrompt, }) => { const { colors } = useTheme(); const styles = createStyles(colors); @@ -25,11 +21,7 @@ const HeaderTitle: React.FC = ({ const doubleTapRef = useRef(); const handleSingleTap = () => { - if (!isShowSystemPrompt && !showUsage) { - onShowSystemPrompt(); - } else { - setShowUsage(!showUsage); - } + setShowUsage(!showUsage); }; return ( diff --git a/react-native/src/chat/component/ModelIconButton.tsx b/react-native/src/chat/component/ModelIconButton.tsx index 36aee6b0..76d28999 100644 --- a/react-native/src/chat/component/ModelIconButton.tsx +++ b/react-native/src/chat/component/ModelIconButton.tsx @@ -28,7 +28,7 @@ const styles = StyleSheet.create({ height: 60, justifyContent: 'center', alignItems: 'center', - paddingRight: 5, + paddingRight: 12, }, icon: { width: 28, diff --git a/react-native/src/chat/component/PromptListComponent.tsx b/react-native/src/chat/component/PromptListComponent.tsx index 0912dd81..6d72b08c 100644 --- a/react-native/src/chat/component/PromptListComponent.tsx +++ b/react-native/src/chat/component/PromptListComponent.tsx @@ -49,7 +49,7 @@ export const PromptListComponent: React.FC = ({ const navigation = useNavigation(); const isImageMode = chatMode === ChatMode.Image; const [isNovaSonic, setIsNovaSonic] = useState( - getTextModel().modelId.includes('nova-sonic') + getTextModel().modelId.includes('sonic') ); const promptType = isImageMode ? 'image' : isNovaSonic ? 'voice' : undefined; const [isEditMode, setIsEditMode] = useState(false); @@ -119,7 +119,7 @@ export const PromptListComponent: React.FC = ({ setSelectedPrompt(null); onSelectPromptRef.current(null); } else if (event.event === 'modelChanged') { - const newIsNovaSonic = getTextModel().modelId.includes('nova-sonic'); + const newIsNovaSonic = getTextModel().modelId.includes('sonic'); if (isNovaSonicRef.current && !newIsNovaSonic) { onSwitchedToTextModelRef.current(); } diff --git a/react-native/src/chat/component/WebSearchIconButton.tsx b/react-native/src/chat/component/WebSearchIconButton.tsx new file mode 100644 index 00000000..4f78c948 --- /dev/null +++ b/react-native/src/chat/component/WebSearchIconButton.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Image, StyleSheet, TouchableOpacity } from 'react-native'; +import { + getSearchProvider, + saveSearchProvider, +} from '../../storage/StorageUtils'; +import { getSearchProviderIcon } from '../../utils/SearchIconUtils'; +import { useTheme } from '../../theme'; +import { useAppContext } from '../../history/AppProvider'; +import { SearchEngineOption } from '../../websearch/types'; + +interface WebSearchIconButtonProps { + onPress: () => void; +} + +export const WebSearchIconButton: React.FC = ({ + onPress, +}) => { + const { isDark } = useTheme(); + const { sendEvent } = useAppContext(); + const searchProvider = getSearchProvider(); + const searchIcon = getSearchProviderIcon( + searchProvider as SearchEngineOption, + isDark + ); + + const handlePress = () => { + // If current provider is not disabled (google/bing/baidu/tavily), toggle to disabled + if (searchProvider !== 'disabled') { + saveSearchProvider('disabled'); + sendEvent('searchProviderChanged'); + } else { + // If disabled, show modal to select a provider + onPress(); + } + }; + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: 44, + height: 60, + justifyContent: 'center', + alignItems: 'center', + }, + icon: { + width: 30, + height: 30, + marginBottom: -6, + }, +}); diff --git a/react-native/src/chat/component/WebSearchSelectionModal.tsx b/react-native/src/chat/component/WebSearchSelectionModal.tsx new file mode 100644 index 00000000..791b4fb4 --- /dev/null +++ b/react-native/src/chat/component/WebSearchSelectionModal.tsx @@ -0,0 +1,307 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useAppContext } from '../../history/AppProvider'; +import { + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, + Image, + TouchableWithoutFeedback, + FlatList, + Dimensions, +} from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, + runOnJS, +} from 'react-native-reanimated'; +import Dialog from 'react-native-dialog'; +import { useTheme, ColorScheme } from '../../theme'; +import { getSearchProviderIcon } from '../../utils/SearchIconUtils'; +import { + getSearchProvider, + saveSearchProvider, + getTavilyApiKey, +} from '../../storage/StorageUtils'; +import { SEARCH_PROVIDER_CONFIGS } from '../../websearch/constants/SearchProviderConstants'; +import { SearchEngineOption } from '../../websearch/types'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { RouteParamList } from '../../types/RouteTypes'; +import { isAndroid } from '../../utils/PlatformUtils'; + +interface WebSearchSelectionModalProps { + visible: boolean; + onClose: () => void; + iconPosition?: { x: number; y: number }; +} + +const SCREEN_WIDTH = Dimensions.get('window').width; +const MODAL_HEIGHT = isAndroid ? 244 : 240; + +export const WebSearchSelectionModal: React.FC< + WebSearchSelectionModalProps +> = ({ + visible, + onClose, + iconPosition = { + x: SCREEN_WIDTH - 50, + y: 70, + }, +}) => { + const { colors, isDark } = useTheme(); + const styles = createStyles(colors); + const { sendEvent } = useAppContext(); + const navigation = useNavigation>(); + const [selectedProvider, setSelectedProvider] = useState( + getSearchProvider() as SearchEngineOption + ); + const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); + + const translateX = useSharedValue(100); + const translateY = useSharedValue(100); + const scale = useSharedValue(0.5); + + const startOpenAnimation = useCallback(() => { + translateX.value = -4; + translateY.value = 0; + scale.value = 0; + + translateX.value = withTiming(-4, { duration: 250 }); + translateY.value = withTiming(-MODAL_HEIGHT, { duration: 250 }); + scale.value = withTiming(1, { duration: 250 }); + }, [scale, translateX, translateY]); + + useEffect(() => { + if (visible) { + setSelectedProvider(getSearchProvider() as SearchEngineOption); + startOpenAnimation(); + } + }, [startOpenAnimation, visible]); + + const startCloseAnimation = (callback: () => void) => { + translateX.value = withTiming(-4, { duration: 250 }); + translateY.value = withTiming(0, { duration: 250 }); + scale.value = withTiming(0, { duration: 250 }, () => { + runOnJS(callback)(); + }); + }; + + const handleClose = () => { + startCloseAnimation(onClose); + }; + + const handleProviderSelect = (provider: SearchEngineOption) => { + // Check if Tavily is selected and API key is not configured + if (provider === 'tavily') { + const tavilyApiKey = getTavilyApiKey(); + if (!tavilyApiKey || tavilyApiKey.trim() === '') { + setShowApiKeyDialog(true); + return; + } + } + + setSelectedProvider(provider); + saveSearchProvider(provider); + + sendEvent('searchProviderChanged'); + + startCloseAnimation(() => { + onClose(); + }); + }; + + const handleGoToSettings = () => { + setShowApiKeyDialog(false); + startCloseAnimation(() => { + onClose(); + // Navigate to Settings screen after modal closes + setTimeout(() => { + navigation.navigate('Settings', {}); + }, 300); + }); + }; + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { translateX: translateX.value }, + { translateY: translateY.value }, + { scale: scale.value }, + ], + }; + }); + + const renderProviderItem = ({ + item, + index, + }: { + item: (typeof SEARCH_PROVIDER_CONFIGS)[0]; + index: number; + }) => { + const isSelected = selectedProvider === item.id; + const isLastItem = index === SEARCH_PROVIDER_CONFIGS.length - 1; + + return ( + handleProviderSelect(item.id)}> + + + {item.name} + {isSelected && ( + + )} + + + ); + }; + + if (!visible) { + return null; + } + + return ( + + + + + + + Web Search + + × + + + item.id} + style={styles.providerList} + /> + + + + + + Tavily API Key Required + + Please configure your Tavily API key in Settings before using Tavily + search. + + setShowApiKeyDialog(false)} + /> + + + + ); +}; + +const createStyles = (colors: ColorScheme) => + StyleSheet.create({ + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.1)', + }, + modalContainer: { + backgroundColor: colors.surface, + borderRadius: 10, + padding: 12, + width: 200, + height: MODAL_HEIGHT, + shadowColor: colors.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + }, + modalContainerPositioned: { + position: 'absolute', + right: 10, + transformOrigin: 'right top', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + title: { + fontSize: 16, + fontWeight: '500', + color: colors.text, + }, + closeButton: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: colors.surface, + justifyContent: 'center', + alignItems: 'center', + }, + closeButtonText: { + fontSize: 16, + lineHeight: 18, + textAlign: 'center', + color: colors.textSecondary, + }, + providerList: { + paddingRight: 8, + }, + providerItem: { + paddingVertical: 12, + borderBottomWidth: 0.5, + borderBottomColor: colors.borderLight, + }, + providerItemLastItem: { + borderBottomWidth: 0, + }, + providerItemContent: { + flexDirection: 'row', + alignItems: 'center', + paddingTop: 2, + }, + providerIcon: { + width: 20, + height: 20, + borderRadius: 10, + marginRight: 10, + }, + providerName: { + fontSize: 14, + flex: 1, + color: colors.text, + }, + checkIcon: { + width: 16, + height: 16, + }, + }); diff --git a/react-native/src/chat/component/markdown/ChunkedCodeView.tsx b/react-native/src/chat/component/markdown/ChunkedCodeView.tsx new file mode 100644 index 00000000..e3ab8330 --- /dev/null +++ b/react-native/src/chat/component/markdown/ChunkedCodeView.tsx @@ -0,0 +1,265 @@ +import React, { + memo, + useRef, + useState, + useEffect, + type FunctionComponent, +} from 'react'; +import { + Platform, + ScrollView, + type ScrollViewProps, + type StyleProp, + StyleSheet, + Text, + TextInput, + type TextStyle, + View, + type ViewStyle, +} from 'react-native'; + +// Number of lines per chunk +const CHUNK_SIZE = 100; + +// Throttle interval for the last chunk updates (ms) +const LAST_CHUNK_THROTTLE_MS = 100; + +interface ChunkedCodeViewProps { + code: string; + textStyle?: StyleProp; + backgroundColor?: string; + scrollViewProps?: ScrollViewProps; + containerStyle?: StyleProp; +} + +interface ChunkTextProps { + content: string; + textStyle?: StyleProp; + isComplete: boolean; +} + +// Memoized chunk component - won't re-render if content is complete +const ChunkText: FunctionComponent = memo( + ({ content, textStyle }) => { + if (Platform.OS === 'ios') { + return ( + + {content} + + ); + } + return {content}; + }, + (prevProps, nextProps) => { + // If the chunk was already complete, never re-render + if (prevProps.isComplete) { + return true; + } + // Otherwise, only re-render if content changed + return prevProps.content === nextProps.content; + } +); + +// Incremental line tracking - only process new content, O(delta) instead of O(n) +const useChunkedCode = (code: string): string[] => { + // Track all lines incrementally + const linesRef = useRef([]); + // Track the position we've processed up to + const processedLengthRef = useRef(0); + // Track if last line was incomplete (no trailing newline) + const incompleteLineRef = useRef(''); + // Cache completed chunks + const completedChunksRef = useRef([]); + + // Throttled last chunk state + const [throttledLastChunk, setThrottledLastChunk] = useState(''); + const lastUpdateTimeRef = useRef(0); + const pendingUpdateRef = useRef | null>(null); + const latestLastChunkRef = useRef(''); + + // State to track complete chunks for rendering + const [completeChunks, setCompleteChunks] = useState([]); + + // Incrementally process new content + useEffect(() => { + const prevLength = processedLengthRef.current; + + // Handle reset case (new code block or code got shorter) + if ( + code.length < prevLength || + !code.startsWith(code.slice(0, prevLength)) + ) { + // Reset everything + linesRef.current = []; + processedLengthRef.current = 0; + incompleteLineRef.current = ''; + completedChunksRef.current = []; + setCompleteChunks([]); + } + + // Get only the new content + const newContent = code.slice(processedLengthRef.current); + if (!newContent) { + return; + } + + // Process new content + const newParts = newContent.split('\n'); + + if (newParts.length > 0) { + // First part completes the previous incomplete line + if (incompleteLineRef.current) { + // Complete the last line + const lastLineIndex = linesRef.current.length - 1; + if (lastLineIndex >= 0) { + linesRef.current[lastLineIndex] += newParts[0]; + } else { + linesRef.current.push(newParts[0]); + } + } else { + // No incomplete line, first part is a new line + linesRef.current.push(newParts[0]); + } + + // Add remaining complete lines + for (let i = 1; i < newParts.length; i++) { + linesRef.current.push(newParts[i]); + } + + // Check if last line is incomplete (code doesn't end with newline) + incompleteLineRef.current = code.endsWith('\n') + ? '' + : newParts[newParts.length - 1]; + } + + processedLengthRef.current = code.length; + + // Now calculate chunks from lines + const totalLines = linesRef.current.length; + const completeChunkCount = Math.floor(totalLines / CHUNK_SIZE); + + // Build/update complete chunks + let chunksChanged = false; + for ( + let i = completedChunksRef.current.length; + i < completeChunkCount; + i++ + ) { + const start = i * CHUNK_SIZE; + const end = start + CHUNK_SIZE; + const chunk = linesRef.current.slice(start, end).join('\n'); + completedChunksRef.current.push(chunk); + chunksChanged = true; + } + + if (chunksChanged) { + setCompleteChunks([...completedChunksRef.current]); + } + + // Calculate last chunk content + const remainingStart = completeChunkCount * CHUNK_SIZE; + const rawLastChunk = + remainingStart < totalLines + ? linesRef.current.slice(remainingStart).join('\n') + : ''; + + // Throttle last chunk updates + latestLastChunkRef.current = rawLastChunk; + + const now = Date.now(); + const timeSinceLastUpdate = now - lastUpdateTimeRef.current; + + // Clear any pending update + if (pendingUpdateRef.current) { + clearTimeout(pendingUpdateRef.current); + pendingUpdateRef.current = null; + } + + if (timeSinceLastUpdate >= LAST_CHUNK_THROTTLE_MS) { + // Enough time has passed, update immediately + setThrottledLastChunk(rawLastChunk); + lastUpdateTimeRef.current = now; + } else { + // Schedule update for remaining time + const remainingTime = LAST_CHUNK_THROTTLE_MS - timeSinceLastUpdate; + pendingUpdateRef.current = setTimeout(() => { + setThrottledLastChunk(latestLastChunkRef.current); + lastUpdateTimeRef.current = Date.now(); + pendingUpdateRef.current = null; + }, remainingTime); + } + + return () => { + if (pendingUpdateRef.current) { + clearTimeout(pendingUpdateRef.current); + } + }; + }, [code]); + + // Combine complete chunks with throttled last chunk + if (throttledLastChunk) { + return [...completeChunks, throttledLastChunk]; + } + return completeChunks.length > 0 ? completeChunks : code ? [code] : []; +}; + +/** + * ChunkedCodeView - Optimized code renderer using chunked rendering + * + * Splits code into chunks of CHUNK_SIZE lines. Completed chunks are memoized + * and won't re-render, only the last active chunk updates during streaming. + * + * Features: + * - Unified horizontal scrolling for all chunks + * - Vertical scrolling passes through to parent + * - O(1) render cost for completed chunks + * - Time-based throttling (200ms) for the last chunk to reduce render frequency + * - Incremental line tracking - O(delta) instead of O(n) for split operations + */ +const ChunkedCodeView: FunctionComponent = ({ + code, + textStyle, + backgroundColor, + scrollViewProps, + containerStyle, +}) => { + const chunks = useChunkedCode(code); + + return ( + + + {chunks.map((chunk, index) => ( + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + chunksContainer: { + flexDirection: 'column', + }, + chunkText: { + // No additional styling needed, inherits from textStyle + }, +}); + +export default ChunkedCodeView; diff --git a/react-native/src/chat/component/markdown/CustomCodeHighlighter.tsx b/react-native/src/chat/component/markdown/CustomCodeHighlighter.tsx index 8247c6be..19ca5d18 100644 --- a/react-native/src/chat/component/markdown/CustomCodeHighlighter.tsx +++ b/react-native/src/chat/component/markdown/CustomCodeHighlighter.tsx @@ -6,6 +6,8 @@ import React, { useCallback, memo, useRef, + useState, + useEffect, } from 'react'; import { Platform, @@ -25,6 +27,11 @@ import SyntaxHighlighter, { import transform, { StyleTuple } from 'css-to-react-native'; import { isMac } from '../../../App.tsx'; import { trimNewlines } from 'trim-newlines'; +import ChunkedCodeView from './ChunkedCodeView'; + +// Streaming optimization constants +// Time (ms) to wait after last content change before applying syntax highlighting +const STREAMING_IDLE_THRESHOLD_MS = 400; type ReactStyle = Record; type HighlighterStyleSheet = { [key: string]: TextStyle }; @@ -89,6 +96,45 @@ export const CustomCodeHighlighter: FunctionComponent = ({ [hljsStyle] ); + // Streaming detection state + const childrenString = String(children); + // Small code blocks always show highlighted, large ones start with plain text + const [showHighlighted, setShowHighlighted] = useState(false); + const streamingTimerRef = useRef | null>(null); + const prevLengthRef = useRef(childrenString.length); + + useEffect(() => { + const wasGrowing = childrenString.length > prevLengthRef.current; + prevLengthRef.current = childrenString.length; + + // Clear existing timer + if (streamingTimerRef.current) { + clearTimeout(streamingTimerRef.current); + streamingTimerRef.current = null; + } + + // For large code blocks: disable highlighting during streaming, re-enable after idle + if (wasGrowing) { + setShowHighlighted(false); + } + // Always set timer to enable highlighting after content stabilizes + if (!showHighlighted) { + streamingTimerRef.current = setTimeout(() => { + setShowHighlighted(true); + streamingTimerRef.current = null; + }, STREAMING_IDLE_THRESHOLD_MS); + } + }, [childrenString.length, showHighlighted]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (streamingTimerRef.current) { + clearTimeout(streamingTimerRef.current); + } + }; + }, []); + const getStylesForNode = useCallback( (node: rendererNode): TextStyle[] => { const classes: string[] = node.properties?.className ?? []; @@ -99,13 +145,13 @@ export const CustomCodeHighlighter: FunctionComponent = ({ [stylesheet] ); - // Calculate base text style once + // Calculate base text style once - used for both PlainTextCodeView and highlighted view const baseTextStyle = useMemo( () => [textStyle, { color: stylesheet.hljs?.color }], [textStyle, stylesheet.hljs?.color] ); - // Cache of previously processed nodes + // Cache of previously processed nodes (must be before conditional return for hooks rules) const processedNodesCache = useRef([]); const prevNodesLength = useRef(0); @@ -159,7 +205,16 @@ export const CustomCodeHighlighter: FunctionComponent = ({ const renderNode = useCallback( (nodes: rendererNode[]): ReactNode => { // Calculate margin bottom value once - const scale = rest.language === 'mermaid' ? 1.75 : isMac ? 3 : 2.75; + const scale = + rest.language === 'mermaid' + ? 1.75 + : rest.language === 'html' + ? isMac + ? 2 + : 1.85 + : isMac + ? 3 + : 2.82; const marginBottomValue = -nodes.length * scale; // Optimization for streaming content - only process new nodes @@ -209,13 +264,13 @@ export const CustomCodeHighlighter: FunctionComponent = ({ nodes.reduce((acc, node, index) => { const keyPrefixWithIndex = `${keyPrefix}_${index}`; if (node.children) { - const styles = StyleSheet.flatten([ + const nodeStyles = StyleSheet.flatten([ textStyle, { color: stylesheet.hljs?.color }, getStylesForNode(node), ]); acc.push( - + {renderAndroidNode(node.children, `${keyPrefixWithIndex}_child`)} ); @@ -251,6 +306,24 @@ export const CustomCodeHighlighter: FunctionComponent = ({ [stylesheet, scrollViewProps, containerStyle, renderNode, renderAndroidNode] ); + // Determine if we should show highlighting + // HTML never gets highlighted; for others, use internal showHighlighted state + const isHtml = rest.language === 'html'; + const shouldHighlight = isHtml ? false : showHighlighted; + + // During streaming, render chunked plain text for performance + if (!shouldHighlight) { + return ( + + ); + } + return ( import('./CustomCodeHighlighter')); let mathViewIndex = 0; @@ -101,11 +103,17 @@ const MemoizedCodeHighlighter = React.memo( language, colors, isDark, + onPreviewToggle, }: { text: string; language?: string; colors: ColorScheme; isDark: boolean; + onPreviewToggle?: ( + expanded: boolean, + height: number, + animated: boolean + ) => void; }) => { const styles = createCustomStyles(colors); // Use useRef to always capture the latest text value @@ -128,6 +136,18 @@ const MemoizedCodeHighlighter = React.memo( ); } + if (language === 'html') { + return ( + + ); + } + return ( @@ -163,11 +183,15 @@ const MemoizedCodeHighlighter = React.memo( if (prevProps.language === 'mermaid' || nextProps.language === 'mermaid') { return false; } + if (prevProps.language === 'html' || nextProps.language === 'html') { + return false; + } return ( prevProps.text === nextProps.text && prevProps.language === nextProps.language && prevProps.colors === nextProps.colors && - prevProps.isDark === nextProps.isDark + prevProps.isDark === nextProps.isDark && + prevProps.onPreviewToggle === nextProps.onPreviewToggle ); } ); @@ -181,16 +205,30 @@ export class CustomMarkdownRenderer private colors: ColorScheme; private styles: ReturnType; private isDark: boolean; + private citations: Citation[]; + private onPreviewToggle?: ( + expanded: boolean, + height: number, + animated: boolean + ) => void; constructor( private onImagePress: (pressMode: PressMode, url: string) => void, colors: ColorScheme, - isDark: boolean + isDark: boolean, + citations: Citation[] = [], + onPreviewToggle?: ( + expanded: boolean, + height: number, + animated: boolean + ) => void ) { super(); this.colors = colors; this.isDark = isDark; this.styles = createCustomStyles(colors); + this.citations = citations; + this.onPreviewToggle = onPreviewToggle; } getTextView(children: string | ReactNode[], styles?: TextStyle): ReactNode { @@ -211,6 +249,55 @@ export class CustomMarkdownRenderer return this.getTextView(text, styles); } + // Parse citation marks [1], [2], [3] etc. and replace with CitationBadge components + private parseCitationMarks(text: string): ReactNode[] { + if (!this.citations || this.citations.length === 0) { + return [text]; + } + + // Regular expression to match [number] pattern + const citationRegex = /\[(\d+)\]/g; + const parts: ReactNode[] = []; + let lastIndex = 0; + let match; + + while ((match = citationRegex.exec(text)) !== null) { + const citationNumber = parseInt(match[1], 10); + const citation = this.citations.find(c => c.number === citationNumber); + + // Add text before the citation mark + if (match.index > lastIndex) { + parts.push(text.substring(lastIndex, match.index)); + } + + // Add citation badge if we have a matching citation + if (citation) { + // Add space before citation badge + parts.push(' '); + parts.push( + + ); + // Add space after citation badge + } else { + // If no matching citation found, keep the original text + parts.push(match[0]); + } + + lastIndex = match.index + match[0].length; + } + + // Add remaining text after the last match + if (lastIndex < text.length) { + parts.push(text.substring(lastIndex)); + } + + return parts.length > 0 ? parts : [text]; + } + codespan(text: string, styles?: TextStyle): ReactNode { return this.getTextView(text, { ...styles, @@ -222,7 +309,12 @@ export class CustomMarkdownRenderer if (Array.isArray(text)) { return this.getNodeForTextArray(text, styles); } - return this.getTextView(text, styles); + // Parse citation marks in the text + const parsedContent = this.parseCitationMarks(text); + if (parsedContent.length === 1 && typeof parsedContent[0] === 'string') { + return this.getTextView(parsedContent[0], styles); + } + return this.getTextView(parsedContent, styles); } strong(children: string | ReactNode[], styles?: TextStyle): ReactNode { @@ -302,8 +394,12 @@ export class CustomMarkdownRenderer _textStyle?: TextStyle ): ReactNode { if (text && text !== '') { - const componentKey = - language === 'mermaid' ? 'mermaid-code-block' : this.getKey(); + let componentKey = this.getKey(); + if (language === 'mermaid') { + componentKey = 'mermaid-code-block'; + } else if (language === 'html') { + componentKey = 'html-code-block'; + } return ( ); } else { diff --git a/react-native/src/chat/component/markdown/HtmlCodeRenderer.tsx b/react-native/src/chat/component/markdown/HtmlCodeRenderer.tsx new file mode 100644 index 00000000..34f04b3a --- /dev/null +++ b/react-native/src/chat/component/markdown/HtmlCodeRenderer.tsx @@ -0,0 +1,319 @@ +import React, { + useState, + Suspense, + useRef, + useCallback, + forwardRef, + useImperativeHandle, + useEffect, +} from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { ColorScheme } from '../../../theme'; +import HtmlPreviewRenderer from './HtmlPreviewRenderer'; +import { CopyButton } from './CustomMarkdownRenderer'; +import { vs2015, github } from 'react-syntax-highlighter/dist/esm/styles/hljs'; +import { Platform } from 'react-native'; + +const CustomCodeHighlighter = React.lazy( + () => import('./CustomCodeHighlighter') +); + +interface HtmlCodeRendererProps { + text: string; + colors: ColorScheme; + isDark: boolean; + onCopy: () => void; + onPreviewToggle?: ( + expanded: boolean, + height: number, + animated: boolean + ) => void; +} + +interface HtmlCodeRendererRef { + updateContent: (newText: string) => void; +} + +interface HtmlPreviewRendererRef { + updateContent: (newCode: string) => void; +} + +// Check if HTML content is complete (ends with ) +const isHtmlComplete = (html: string): boolean => { + return html.trimEnd().toLowerCase().endsWith(''); +}; + +const HtmlCodeRenderer = forwardRef( + ({ text, colors, isDark, onCopy, onPreviewToggle }, ref) => { + // Default to preview mode when HTML is complete + const [showPreview, setShowPreview] = useState(() => isHtmlComplete(text)); + const [currentText, setCurrentText] = useState(text); + const [hasAutoSwitched, setHasAutoSwitched] = useState(() => + isHtmlComplete(text) + ); + const htmlRendererRef = useRef(null); + const codeContainerRef = useRef(null); + const previewContainerRef = useRef(null); + const codeHeightRef = useRef(0); + const previewHeightRef = useRef(0); + const styles = createStyles(colors); + const hljsStyle = isDark ? vs2015 : github; + + const updateContent = useCallback( + (newText: string) => { + setCurrentText(newText); + if (showPreview && htmlRendererRef.current) { + htmlRendererRef.current.updateContent(newText); + } + }, + [showPreview] + ); + + useImperativeHandle( + ref, + () => ({ + updateContent, + }), + [updateContent] + ); + + useEffect(() => { + setCurrentText(text); + }, [text]); + + // Auto-switch to preview when HTML is complete (ends with ) + useEffect(() => { + if (hasAutoSwitched || showPreview) { + return; + } + + if (isHtmlComplete(text)) { + setShowPreview(true); + setHasAutoSwitched(true); + } + }, [text, hasAutoSwitched, showPreview]); + + const setCodeMode = useCallback(() => { + if (!showPreview) { + return; + } + // Switching from preview to code + if (previewHeightRef.current === 0) { + // Need to measure current preview height first + previewContainerRef.current?.measure((_x, _y, _width, height) => { + previewHeightRef.current = height; + setShowPreview(false); + setTimeout(() => { + codeContainerRef.current?.measure( + (_x2, _y2, _width2, codeHeight) => { + codeHeightRef.current = codeHeight; + // heightDiff > 0 means code is taller (expanding), < 0 means code is shorter (collapsing) + const heightDiff = codeHeight - previewHeightRef.current; + if (heightDiff !== 0) { + onPreviewToggle?.(heightDiff > 0, Math.abs(heightDiff), true); + } + } + ); + }, 150); + }); + } else { + setShowPreview(false); + if (codeHeightRef.current === 0) { + setTimeout(() => { + codeContainerRef.current?.measure((_x, _y, _width, codeHeight) => { + codeHeightRef.current = codeHeight; + const heightDiff = codeHeight - previewHeightRef.current; + if (heightDiff !== 0) { + onPreviewToggle?.(heightDiff > 0, Math.abs(heightDiff), true); + } + }); + }, 150); + } else { + const heightDiff = codeHeightRef.current - previewHeightRef.current; + if (heightDiff !== 0) { + setTimeout(() => { + onPreviewToggle?.(heightDiff > 0, Math.abs(heightDiff), false); + }, 0); + } + } + } + }, [showPreview, onPreviewToggle]); + + const setPreviewMode = useCallback(() => { + if (showPreview) { + return; + } + // Switching from code to preview + if (codeHeightRef.current === 0) { + // Need to measure current code height first + codeContainerRef.current?.measure((_x, _y, _width, height) => { + codeHeightRef.current = height; + setShowPreview(true); + setTimeout(() => { + previewContainerRef.current?.measure( + (_x2, _y2, _width2, previewHeight) => { + previewHeightRef.current = previewHeight; + // heightDiff > 0 means preview is taller (expanding), < 0 means preview is shorter (collapsing) + const heightDiff = previewHeight - codeHeightRef.current; + if (heightDiff !== 0) { + onPreviewToggle?.(heightDiff > 0, Math.abs(heightDiff), true); + } + } + ); + }, 150); + }); + } else { + setShowPreview(true); + if (previewHeightRef.current === 0) { + setTimeout(() => { + previewContainerRef.current?.measure( + (_x, _y, _width, previewHeight) => { + previewHeightRef.current = previewHeight; + const heightDiff = previewHeight - codeHeightRef.current; + if (heightDiff !== 0) { + onPreviewToggle?.(heightDiff > 0, Math.abs(heightDiff), true); + } + } + ); + }, 150); + } else { + const heightDiff = previewHeightRef.current - codeHeightRef.current; + if (heightDiff !== 0) { + setTimeout(() => { + onPreviewToggle?.(heightDiff > 0, Math.abs(heightDiff), false); + }, 0); + } + } + } + }, [showPreview, onPreviewToggle]); + + return ( + + + + + + + code + + + + + preview + + + + + + + + {showPreview ? ( + + + + ) : ( + + Loading...}> + + {currentText} + + + + )} + + ); + } +); + +const createStyles = (colors: ColorScheme) => + StyleSheet.create({ + container: { + borderRadius: 8, + overflow: 'hidden', + backgroundColor: colors.input, + marginVertical: 6, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: colors.borderLight, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + paddingVertical: 2, + paddingHorizontal: 4, + }, + leftSection: { + flexDirection: 'row', + alignItems: 'center', + }, + tabContainer: { + flexDirection: 'row', + backgroundColor: colors.input, + borderRadius: 6, + padding: 2, + }, + tabButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 4, + marginHorizontal: 1, + }, + activeTab: { + backgroundColor: colors.text, + }, + tabText: { + fontSize: 14, + color: colors.text, + fontWeight: '500', + opacity: 0.6, + }, + activeTabText: { + color: colors.background, + opacity: 1, + }, + loading: { + padding: 12, + color: colors.text, + }, + codeText: { + fontSize: 14, + paddingVertical: 1.3, + fontFamily: Platform.OS === 'ios' ? 'Menlo-Regular' : 'monospace', + color: colors.text, + }, + htmlRenderer: { + marginVertical: 0, + minHeight: 100, + }, + }); + +export default HtmlCodeRenderer; diff --git a/react-native/src/chat/component/markdown/HtmlFullScreenViewer.tsx b/react-native/src/chat/component/markdown/HtmlFullScreenViewer.tsx new file mode 100644 index 00000000..1b939a22 --- /dev/null +++ b/react-native/src/chat/component/markdown/HtmlFullScreenViewer.tsx @@ -0,0 +1,182 @@ +import React, { + useMemo, + useState, + useRef, + useCallback, + useEffect, +} from 'react'; +import { + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, + StatusBar, + Platform, + Dimensions, +} from 'react-native'; +import { WebView, WebViewMessageEvent } from 'react-native-webview'; +import { useTheme } from '../../../theme'; +import { isMac } from '../../../App.tsx'; +import { injectErrorScript, commonWebViewProps } from './htmlUtils'; + +interface HtmlFullScreenViewerProps { + visible: boolean; + onClose: () => void; + code: string; +} + +const HtmlFullScreenViewer: React.FC = ({ + visible, + onClose, + code, +}) => { + const { colors, isDark } = useTheme(); + const webViewRef = useRef(null); + const [hasError, setHasError] = useState(false); + const [screenData, setScreenData] = useState(Dimensions.get('window')); + const [isLandscape, setIsLandscape] = useState( + isMac ? false : screenData.width > screenData.height + ); + + // Listen for orientation changes + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setScreenData(window); + setIsLandscape(isMac ? true : window.width > window.height); + }); + + return () => subscription?.remove(); + }, []); + + // Reset error state when modal opens + useEffect(() => { + if (visible) { + setHasError(false); + } + }, [visible]); + + const handleWebViewMessage = useCallback((event: WebViewMessageEvent) => { + try { + const message = JSON.parse(event.nativeEvent.data); + + if (message.type === 'rendered') { + setHasError(!message.success); + } + } catch (error) { + console.log('[HtmlFullScreenViewer] Message parse error:', error); + } + }, []); + + const handleError = useCallback(() => { + setHasError(true); + }, []); + + const htmlContent = useMemo(() => injectErrorScript(code), [code]); + + const styles = StyleSheet.create({ + modal: { + flex: 1, + backgroundColor: isDark ? '#000000' : '#ffffff', + }, + closeButtonTopLeft: { + position: 'absolute', + top: + Platform.OS === 'ios' + ? isLandscape + ? 40 + : 60 + : (StatusBar.currentHeight || 20) + (isLandscape ? 10 : 20), + left: isLandscape ? 40 : 20, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(50, 50, 50, 0.8)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + closeButtonX: { + fontSize: 20, + fontWeight: '400', + marginBottom: -2, + color: '#ffffff', + lineHeight: 20, + }, + webViewContainer: { + flex: 1, + backgroundColor: isDark ? '#1a1a1a' : '#ffffff', + }, + webView: { + flex: 1, + backgroundColor: 'transparent', + }, + errorContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: isDark ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,1)', + zIndex: 998, + }, + errorText: { + marginTop: 10, + fontSize: 16, + color: colors.text, + }, + }); + + if (!visible) { + return null; + } + + return ( + + + + + {/* Close button in top-left */} + + × + + + {/* WebView */} + + + + + {/* Error overlay */} + {hasError && ( + + {'Invalid HTML'} + + )} + + + ); +}; + +export default HtmlFullScreenViewer; diff --git a/react-native/src/chat/component/markdown/HtmlPreviewRenderer.tsx b/react-native/src/chat/component/markdown/HtmlPreviewRenderer.tsx new file mode 100644 index 00000000..920acbd4 --- /dev/null +++ b/react-native/src/chat/component/markdown/HtmlPreviewRenderer.tsx @@ -0,0 +1,465 @@ +import React, { + useMemo, + useState, + useCallback, + forwardRef, + useImperativeHandle, + useRef, +} from 'react'; +import { WebView, WebViewMessageEvent } from 'react-native-webview'; +import { + ViewStyle, + TouchableOpacity, + View, + Text, + StyleSheet, + Image, + Alert, + TextInput, + Modal, + Platform, +} from 'react-native'; +import { ColorScheme, useTheme } from '../../../theme'; +import HtmlFullScreenViewer from './HtmlFullScreenViewer'; +import RNFS from 'react-native-fs'; +import { saveApp, generateAppId } from '../../../storage/StorageUtils'; +import { SavedApp } from '../../../types/Chat'; +import { injectErrorScript, commonWebViewProps } from './htmlUtils'; + +interface HtmlPreviewRendererProps { + code: string; + style?: ViewStyle; +} + +interface HtmlPreviewRendererRef { + updateContent: (newCode: string) => void; +} + +// App screenshots directory +const APP_SCREENSHOTS_DIR = `${RNFS.DocumentDirectoryPath}/app`; + +const HtmlPreviewRenderer = forwardRef< + HtmlPreviewRendererRef, + HtmlPreviewRendererProps +>(({ code, style }, ref) => { + const [showFullScreen, setShowFullScreen] = useState(false); + const [hasError, setHasError] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + const [appName, setAppName] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const webViewRef = useRef(null); + const { colors } = useTheme(); + const styles = createStyles(colors); + + const updateContent = useCallback((_newCode: string) => { + // Content updates are handled by htmlContent useMemo via code prop + }, []); + + useImperativeHandle( + ref, + () => ({ + updateContent, + }), + [updateContent] + ); + + // Ensure app directory exists + const ensureAppDir = async () => { + const exists = await RNFS.exists(APP_SCREENSHOTS_DIR); + if (!exists) { + await RNFS.mkdir(APP_SCREENSHOTS_DIR); + } + }; + + // Capture screenshot using html2canvas + const captureScreenshot = useCallback(() => { + return ` + (function() { + // Load html2canvas from CDN + if (typeof html2canvas === 'undefined') { + var script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'; + script.onload = function() { + captureNow(); + }; + script.onerror = function() { + // Fallback if CDN fails + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'screenshot_error', + message: 'Failed to load html2canvas' + })); + }; + document.head.appendChild(script); + } else { + captureNow(); + } + + function captureNow() { + var pixelRatio = window.devicePixelRatio || 1; + var captureWidth = Math.min(document.body.scrollWidth || window.innerWidth, 800); + var captureHeight = Math.min(document.body.scrollHeight || window.innerHeight, 800); + + html2canvas(document.body, { + backgroundColor: null, + useCORS: true, + allowTaint: true, + scale: Math.min(pixelRatio, 1), + width: captureWidth, + height: captureHeight, + windowWidth: captureWidth, + windowHeight: captureHeight, + x: 0, + y: 0, + scrollX: 0, + scrollY: 0 + }).then(function(canvas) { + var dataURL = canvas.toDataURL('image/jpeg', 0.9); + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'screenshot_success', + data: dataURL + })); + }).catch(function(error) { + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'screenshot_error', + message: error.message || 'Screenshot failed' + })); + }); + } + })(); + true; + `; + }, []); + + const handleScreenshotMessage = useCallback( + async (data: string) => { + try { + await ensureAppDir(); + + const appId = generateAppId(); + const base64Data = data.replace(/^data:image\/(png|jpeg);base64,/, ''); + const screenshotFileName = `${appId}.jpg`; + const screenshotFullPath = `${APP_SCREENSHOTS_DIR}/${screenshotFileName}`; + + await RNFS.writeFile(screenshotFullPath, base64Data, 'base64'); + + // Store relative path for iOS (to survive app updates), full file:// URI for Android + const storedPath = + Platform.OS === 'android' + ? `file://${screenshotFullPath}` + : `app/${screenshotFileName}`; + + const app: SavedApp = { + id: appId, + name: appName.trim(), + htmlCode: code, + screenshotPath: storedPath, + createdAt: Date.now(), + }; + + saveApp(app); + + setIsSaving(false); + setShowSaveModal(false); + setAppName(''); + Alert.alert('Success', `App "${appName}" saved successfully!`); + } catch (error) { + console.error('[HtmlPreview] Save error:', error); + setIsSaving(false); + Alert.alert('Error', 'Failed to save app'); + } + }, + [appName, code] + ); + + const handleSaveApp = useCallback(async () => { + if (!appName.trim()) { + Alert.alert('Error', 'Please enter an app name'); + return; + } + if (appName.length > 20) { + Alert.alert('Error', 'App name must be 20 characters or less'); + return; + } + + setIsSaving(true); + + // Capture screenshot using html2canvas + if (webViewRef.current) { + webViewRef.current.injectJavaScript(captureScreenshot()); + } + }, [appName, captureScreenshot]); + + const htmlContent = useMemo(() => injectErrorScript(code), [code]); + + const handleSaveWithoutScreenshot = useCallback(async () => { + try { + await ensureAppDir(); + + const appId = generateAppId(); + + const app: SavedApp = { + id: appId, + name: appName.trim(), + htmlCode: code, + screenshotPath: undefined, + createdAt: Date.now(), + }; + + saveApp(app); + + setIsSaving(false); + setShowSaveModal(false); + setAppName(''); + Alert.alert('Success', `App "${appName}" saved (without preview)`); + } catch (error) { + console.error('[HtmlPreview] Save error:', error); + setIsSaving(false); + Alert.alert('Error', 'Failed to save app'); + } + }, [appName, code]); + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + try { + const message = JSON.parse(event.nativeEvent.data); + + // Handle console logs from WebView + if (message.type === 'console_log') { + console.log('[HtmlPreview]', message.message); + return; + } + + if (message.type === 'console_error') { + console.error('[HtmlPreview]', message.message); + setHasError(true); + return; + } + + if (message.type === 'rendered' || message.type === 'update_rendered') { + setHasError(!message.success); + } + + if (message.type === 'screenshot_success') { + handleScreenshotMessage(message.data); + } + + if (message.type === 'screenshot_error') { + console.error('[HtmlPreview] Screenshot error:', message.message); + // Save without screenshot + handleSaveWithoutScreenshot(); + } + } catch (error) { + console.log('[HtmlPreview] Raw message:', event.nativeEvent.data); + } + }, + [handleScreenshotMessage, handleSaveWithoutScreenshot] + ); + + const handleError = useCallback(() => { + setHasError(true); + }, []); + + return ( + <> + + setShowFullScreen(true)} + activeOpacity={0.8} + style={styles.webViewContainer}> + + + {hasError && ( + + {'Invalid HTML'} + + )} + + + {/* Save Button */} + setShowSaveModal(true)}> + + + + + {/* Save Modal */} + setShowSaveModal(false)}> + setShowSaveModal(false)}> + true}> + Save App + setAppName(text.slice(0, 20))} + maxLength={20} + autoFocus={true} + returnKeyType="done" + onSubmitEditing={handleSaveApp} + /> + + { + setShowSaveModal(false); + setAppName(''); + }}> + Cancel + + + + {isSaving ? 'Saving...' : 'Save'} + + + + + + + + setShowFullScreen(false)} + code={code} + /> + + ); +}); + +const createStyles = (colors: ColorScheme) => + StyleSheet.create({ + container: { + position: 'relative' as const, + }, + webViewContainer: { + position: 'relative' as const, + }, + webView: { + height: 480, + backgroundColor: 'transparent' as const, + }, + errorContainer: { + position: 'absolute' as const, + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center' as const, + alignItems: 'center' as const, + backgroundColor: colors.input, + }, + errorText: { + marginTop: 10, + fontSize: 14, + color: colors.text, + }, + saveButton: { + position: 'absolute' as const, + bottom: 12, + right: 12, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + saveIcon: { + width: 22, + height: 22, + tintColor: '#ffffff', + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + modalContent: { + backgroundColor: colors.background, + borderRadius: 12, + padding: 20, + width: '80%', + maxWidth: 320, + }, + modalTitle: { + fontSize: 18, + fontWeight: '600' as const, + color: colors.text, + marginBottom: 16, + textAlign: 'center' as const, + }, + modalInput: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + padding: Platform.OS === 'ios' ? 12 : 10, + fontSize: 16, + color: colors.text, + backgroundColor: colors.input, + marginBottom: 16, + }, + modalButtons: { + flexDirection: 'row' as const, + justifyContent: 'space-between' as const, + }, + modalCancelButton: { + flex: 1, + padding: 12, + borderRadius: 8, + backgroundColor: colors.border, + marginRight: 8, + }, + modalCancelText: { + color: colors.text, + textAlign: 'center' as const, + fontSize: 16, + fontWeight: '500' as const, + }, + modalSaveButton: { + flex: 1, + padding: 12, + borderRadius: 8, + backgroundColor: colors.primary, + marginLeft: 8, + }, + modalButtonDisabled: { + opacity: 0.6, + }, + modalSaveText: { + color: '#ffffff', + textAlign: 'center' as const, + fontSize: 16, + fontWeight: '500' as const, + }, + }); + +export default HtmlPreviewRenderer; diff --git a/react-native/src/chat/component/markdown/MermaidCodeRenderer.tsx b/react-native/src/chat/component/markdown/MermaidCodeRenderer.tsx index 80b9422d..69d6e609 100644 --- a/react-native/src/chat/component/markdown/MermaidCodeRenderer.tsx +++ b/react-native/src/chat/component/markdown/MermaidCodeRenderer.tsx @@ -111,7 +111,6 @@ const MermaidCodeRenderer = forwardRef< Loading...}> = ({ '' ); const fileName = `mermaid_diagram_${Date.now()}.png`; - let filePath = `${RNFS.DocumentDirectoryPath}/${fileName}`; - await RNFS.writeFile(filePath, base64Data, 'base64'); - if (Platform.OS === 'android') { - filePath = 'file://' + filePath; + // On Mac, save directly to Downloads folder + if (isMac) { + try { + const downloadsPath = RNFS.DocumentDirectoryPath.replace( + '/Documents', + '/Downloads' + ); + const filePath = `${downloadsPath}/${fileName}`; + await RNFS.writeFile(filePath, base64Data, 'base64'); + Alert.alert( + 'Success', + `Image saved to Downloads folder:\n${fileName}` + ); + } catch (error) { + console.log('[MermaidFullScreenViewer] Save error:', error); + Alert.alert('Error', 'Failed to save image to Downloads folder'); + } + } else { + // On mobile platforms, use share sheet + let filePath = `${RNFS.DocumentDirectoryPath}/${fileName}`; + await RNFS.writeFile(filePath, base64Data, 'base64'); + + if (Platform.OS === 'android') { + filePath = 'file://' + filePath; + } + + const shareOptions = { + url: filePath, + type: 'image/png', + title: 'Save Mermaid Diagram', + }; + await Share.open(shareOptions); } - const shareOptions = { - url: filePath, - type: 'image/png', - title: 'Save Mermaid Diagram', - }; - await Share.open(shareOptions); } else if (message.type === 'capture_error') { Alert.alert('Error', `Failed to capture image: ${message.message}`); } else if (message.type === 'rendered') { diff --git a/react-native/src/chat/component/markdown/htmlUtils.ts b/react-native/src/chat/component/markdown/htmlUtils.ts new file mode 100644 index 00000000..d1ba4d24 --- /dev/null +++ b/react-native/src/chat/component/markdown/htmlUtils.ts @@ -0,0 +1,56 @@ +/** + * Shared utilities for HTML WebView rendering + */ + +/** + * Injects error handling script into HTML code + * This script reports render success/failure back to React Native + */ +export const injectErrorScript = (htmlCode: string): string => { + const errorHandlingScript = ` + + `; + + if (htmlCode.includes('')) { + return htmlCode.replace('', `${errorHandlingScript}`); + } else if (htmlCode.includes('')) { + return htmlCode.replace('', `${errorHandlingScript}`); + } else { + return htmlCode + errorHandlingScript; + } +}; + +/** + * Common WebView props for HTML rendering + */ +export const commonWebViewProps = { + javaScriptEnabled: true, + domStorageEnabled: true, + allowFileAccess: true, + allowUniversalAccessFromFileURLs: true, + allowFileAccessFromFileURLs: true, + mixedContentMode: 'compatibility' as const, + originWhitelist: ['*'], + scalesPageToFit: false, + showsHorizontalScrollIndicator: false, + showsVerticalScrollIndicator: false, +}; diff --git a/react-native/src/chat/util/BedrockMessageConvertor.ts b/react-native/src/chat/util/BedrockMessageConvertor.ts index c55654c1..1bdfce33 100644 --- a/react-native/src/chat/util/BedrockMessageConvertor.ts +++ b/react-native/src/chat/util/BedrockMessageConvertor.ts @@ -66,10 +66,7 @@ export async function getBedrockMessage( content[0] as TextContent ).text += `\n\n[File: ${fileName}.${fileFormat}]\n${fileTextContent}`; } catch (error) { - console.warn( - `Error reading text content from ${fileName}:`, - error - ); + console.warn('Error reading text content from:', fileName, error); } } else { content.push({ @@ -84,7 +81,7 @@ export async function getBedrockMessage( } } } catch (error) { - console.warn(`Error processing file ${file.fileName}:`, error); + console.warn('Error processing file:', file.fileName, error); } } } diff --git a/react-native/src/chat/util/FaviconUtils.ts b/react-native/src/chat/util/FaviconUtils.ts new file mode 100644 index 00000000..0f2c0b8d --- /dev/null +++ b/react-native/src/chat/util/FaviconUtils.ts @@ -0,0 +1,137 @@ +import { storage } from '../../storage/StorageUtils'; + +const FAVICON_CACHE_KEY = 'favicon_services_map'; +const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days + +type FaviconCache = { + [domain: string]: { + service: string; + timestamp: number; + }; +}; + +/** + * Get favicon URL for a given website URL + * Uses multiple services with automatic selection based on cached fastest response + */ +export const getFaviconUrl = (url: string): string | null => { + try { + const urlObj = new URL(url); + const domain = urlObj.hostname; + + // Get cache map + const cache = getCacheMap(); + const cached = cache[domain]; + + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + return getFaviconServiceUrl(domain, cached.service); + } + + // Detect locale for initial service selection + let locale = 'en'; + try { + locale = Intl.DateTimeFormat().resolvedOptions().locale; + } catch (e) { + console.log('Failed to get locale, using default'); + } + + // Default to favicon.splitbee for non-Chinese regions, Google for Chinese regions + const isChinese = + locale.toLowerCase().includes('cn') || + locale.toLowerCase().includes('zh'); + const defaultService = isChinese ? 'favicon' : 'google'; + + // Start background race to find fastest service (no await, fire and forget) + findFastestService(domain); + + return getFaviconServiceUrl(domain, defaultService); + } catch (error) { + console.log('Failed to parse URL:', error); + return null; + } +}; + +/** + * Get cache map from storage + */ +const getCacheMap = (): FaviconCache => { + try { + const cached = storage.getString(FAVICON_CACHE_KEY); + return cached ? JSON.parse(cached) : {}; + } catch (error) { + return {}; + } +}; + +/** + * Update cache map in storage + */ +const updateCache = (domain: string, service: string) => { + try { + const cache = getCacheMap(); + cache[domain] = { + service, + timestamp: Date.now(), + }; + storage.set(FAVICON_CACHE_KEY, JSON.stringify(cache)); + } catch (error) { + console.log('Failed to update favicon cache:', error); + } +}; + +/** + * Get favicon URL for a specific service + */ +const getFaviconServiceUrl = (domain: string, service: string): string => { + switch (service) { + case 'google': + return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + case 'favicon': + return `https://favicon.splitbee.io/?url=${domain}`; + case 'faviconim': + return `https://favicon.im/${domain}`; + case 'direct': + return `https://${domain}/favicon.ico`; + default: + return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + } +}; + +/** + * Background task to find and cache the fastest favicon service + */ +const findFastestService = async (domain: string) => { + const services = ['google', 'favicon', 'faviconim', 'direct']; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + + try { + // Race all services + const promises = services.map(async service => { + const url = getFaviconServiceUrl(domain, service); + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal, + reactNative: { textStreaming: true }, + }); + if (response.ok) { + return service; + } + throw new Error(`Failed: ${service}`); + }); + + // Get first successful service + const fastestService = await Promise.race( + promises.map(p => p.catch(() => null)) + ); + + if (fastestService) { + // Cache the fastest service using unified cache + updateCache(domain, fastestService); + } + } catch (error) { + // Silent fail - default service will be used + } finally { + clearTimeout(timeout); + } +}; diff --git a/react-native/src/chat/util/ToastUtils.ts b/react-native/src/chat/util/ToastUtils.ts index 637098aa..040c0eb3 100644 --- a/react-native/src/chat/util/ToastUtils.ts +++ b/react-native/src/chat/util/ToastUtils.ts @@ -4,5 +4,7 @@ export const showInfo = (msg: string) => { Toast.show({ type: 'info', text1: msg, + position: 'bottom', + visibilityTime: 1500, }); }; diff --git a/react-native/src/history/AppProvider.tsx b/react-native/src/history/AppProvider.tsx index f7dc47f1..31ec85e1 100644 --- a/react-native/src/history/AppProvider.tsx +++ b/react-native/src/history/AppProvider.tsx @@ -1,4 +1,10 @@ -import React, { createContext, ReactNode, useContext, useState } from 'react'; +import React, { + createContext, + ReactNode, + useCallback, + useContext, + useState, +} from 'react'; import { EventData } from '../types/Chat.ts'; export type DrawerType = 'permanent' | 'slide'; @@ -22,9 +28,9 @@ export const AppProvider: React.FC = ({ children }) => { params?: EventData; } | null>(null); - const sendEvent = (eventName: string, params?: EventData) => { + const sendEvent = useCallback((eventName: string, params?: EventData) => { setEvent({ event: eventName, params: params }); - }; + }, []); const [drawerType, setDrawerType] = useState('permanent'); return ( diff --git a/react-native/src/history/CustomDrawerContent.tsx b/react-native/src/history/CustomDrawerContent.tsx index 3946361e..c7b2c034 100644 --- a/react-native/src/history/CustomDrawerContent.tsx +++ b/react-native/src/history/CustomDrawerContent.tsx @@ -165,11 +165,7 @@ const CustomDrawerContent: React.FC = ({ style={styles.settingsTouch} onPress={() => { setDrawerToPermanent(); - navigation.navigate('Bedrock', { - sessionId: -1, - tapIndex: -2, - mode: ChatMode.Image, - }); + navigation.navigate('ImageGallery'); }}> = ({ /> Image + { + setDrawerToPermanent(); + navigation.navigate('AppGallery'); + }}> + + App + } renderItem={({ item }) => { @@ -296,10 +308,9 @@ const createStyles = (colors: ColorScheme) => height: 24, borderRadius: 12, }, - settingsRightImg: { - width: 16, - height: 16, - marginRight: 8, + appLeftImg: { + width: 24, + height: 24, }, flatList: { marginVertical: 4, diff --git a/react-native/src/image/ImageGalleryScreen.tsx b/react-native/src/image/ImageGalleryScreen.tsx new file mode 100644 index 00000000..a3b7750c --- /dev/null +++ b/react-native/src/image/ImageGalleryScreen.tsx @@ -0,0 +1,373 @@ +import React, { useCallback, useState, useEffect } from 'react'; +import { + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, + FlatList, + Image, + Alert, + Platform, + Dimensions, +} from 'react-native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RouteParamList } from '../types/RouteTypes'; +import { useTheme, ColorScheme } from '../theme'; +import RNFS from 'react-native-fs'; +import { ChatMode } from '../types/Chat'; +import ImageView from 'react-native-image-viewing'; +import { ImageSource } from 'react-native-image-viewing/dist/@types'; +import Share from 'react-native-share'; +import { showInfo } from '../chat/util/ToastUtils'; +import { isMacCatalyst } from '../utils/PlatformUtils'; +import FileViewer from 'react-native-file-viewer'; +import { CustomHeaderRightButton } from '../chat/component/CustomHeaderRightButton'; + +type NavigationProp = NativeStackNavigationProp; + +export interface ImageItem { + id: string; + path: string; + name: string; + createdAt: number; +} + +const getNumColumns = (width: number) => (width > 434 ? 5 : 3); + +function ImageGalleryScreen(): React.JSX.Element { + const navigation = useNavigation(); + const { colors, isDark } = useTheme(); + const [images, setImages] = useState([]); + const [screenWidth, setScreenWidth] = useState( + Dimensions.get('window').width + ); + const numColumns = getNumColumns(screenWidth); + const styles = createStyles(colors, numColumns); + + // ImageView state + const [visible, setIsVisible] = useState(false); + const [viewerIndex, setViewerIndex] = useState(0); + const [imageUrls, setImageUrls] = useState([]); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setScreenWidth(window.width); + }); + return () => subscription?.remove(); + }, []); + + const loadImages = useCallback(async () => { + try { + const documentsDir = RNFS.DocumentDirectoryPath; + const files = await RNFS.readDir(documentsDir); + + const imageFiles = files + .filter( + file => file.name.startsWith('image_') && file.name.endsWith('.png') + ) + .map(file => { + const timestamp = parseInt( + file.name.replace('image_', '').replace('.png', ''), + 10 + ); + return { + id: file.name, + path: Platform.OS === 'ios' ? file.path : `file://${file.path}`, + name: file.name, + createdAt: isNaN(timestamp) + ? file.mtime?.getTime() || 0 + : timestamp, + }; + }) + .sort((a, b) => b.createdAt - a.createdAt); + + setImages(imageFiles); + setImageUrls(imageFiles.map(img => ({ uri: img.path }))); + } catch (error) { + console.log('Error loading images:', error); + } + }, []); + + useFocusEffect( + useCallback(() => { + loadImages(); + }, [loadImages]) + ); + + React.useLayoutEffect(() => { + navigation.setOptions({ + // eslint-disable-next-line react/no-unstable-nested-components + headerRight: () => ( + { + navigation.navigate('Bedrock', { + sessionId: -1, + tapIndex: -2, + mode: ChatMode.Image, + }); + }} + imageSource={ + isDark + ? require('../assets/add_dark.png') + : require('../assets/add.png') + } + /> + ), + title: 'Image Gallery', + }); + }, [navigation, isDark]); + + const handleDeleteImage = useCallback( + (image: ImageItem) => { + Alert.alert( + 'Delete Image', + 'Are you sure you want to delete this image?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + const fullPath = + Platform.OS === 'ios' + ? `${RNFS.DocumentDirectoryPath}/${image.name}` + : image.path.replace('file://', ''); + const exists = await RNFS.exists(fullPath); + if (exists) { + await RNFS.unlink(fullPath); + } + loadImages(); + } catch (error) { + console.log('Error deleting image:', error); + } + }, + }, + ] + ); + }, + [loadImages] + ); + + const handleSaveOrShare = useCallback(async (image: ImageItem) => { + try { + let filePath = + Platform.OS === 'ios' + ? `${RNFS.DocumentDirectoryPath}/${image.name}` + : image.path; + + if (isMacCatalyst) { + // On Mac, save to Downloads folder + const downloadsPath = RNFS.DocumentDirectoryPath.replace( + '/Documents', + '/Downloads' + ); + const destPath = `${downloadsPath}/${image.name}`; + await RNFS.copyFile(filePath, destPath); + Alert.alert( + 'Success', + `Image saved to Downloads folder:\n${image.name}` + ); + } else { + // On mobile, use share sheet + if (Platform.OS === 'android') { + filePath = image.path; + } + const shareOptions = { + url: filePath, + type: 'image/png', + title: 'Save Image', + }; + await Share.open(shareOptions); + } + } catch (error) { + console.log('Error saving/sharing image:', error); + // User cancelled share is not an error + if ((error as Error).message !== 'User did not share') { + showInfo('Action cancelled'); + } + } + }, []); + + const handleOpenImage = useCallback((image: ImageItem, index: number) => { + if (isMacCatalyst) { + // On Mac, use system file viewer + FileViewer.open(image.path).catch(error => { + console.log('Error opening file:', error); + }); + } else { + // On iOS/Android, use ImageView with swipe support + setViewerIndex(index); + setIsVisible(true); + } + }, []); + + const renderImageItem = useCallback( + ({ item, index }: { item: ImageItem; index: number }) => { + return ( + + handleOpenImage(item, index)} + onLongPress={() => handleDeleteImage(item)} + activeOpacity={0.7}> + + + + ); + }, + [styles, handleOpenImage, handleDeleteImage] + ); + + const renderEmptyState = useCallback( + () => ( + + No generated images yet + + Generate images in chat and they will appear here + + + ), + [styles] + ); + + const FooterComponent = useCallback( + ({ imageIndex }: { imageIndex: number }) => { + const currentImage = images[imageIndex]; + if (!currentImage) { + return null; + } + + return ( + + handleSaveOrShare(currentImage)}> + + + { + setIsVisible(false); + setTimeout(() => handleDeleteImage(currentImage), 300); + }}> + + + + ); + }, + [images, handleSaveOrShare, handleDeleteImage, styles] + ); + + return ( + + item.id} + numColumns={numColumns} + contentContainerStyle={styles.listContainer} + columnWrapperStyle={ + images.length > 1 ? styles.columnWrapper : undefined + } + ListEmptyComponent={renderEmptyState} + /> + setIsVisible(false)} + FooterComponent={FooterComponent} + /> + + ); +} + +const createStyles = (colors: ColorScheme, numColumns: number) => { + // 均分宽度,间距通过 paddingLeft/paddingRight 实现 + const cardWidthPercent = numColumns === 5 ? '20%' : '33.333%'; + + return StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: colors.background, + }, + listContainer: { + paddingTop: 12, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8, + flexGrow: 1, + }, + columnWrapper: { + justifyContent: 'flex-start', + }, + imageCard: { + width: cardWidthPercent, + paddingLeft: 4, + paddingRight: 4, + marginBottom: 8, + }, + imageCardInner: { + aspectRatio: 1, + backgroundColor: colors.card, + borderRadius: 8, + overflow: 'hidden', + }, + thumbnail: { + width: '100%', + height: '100%', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + fontSize: 18, + fontWeight: '600', + color: colors.text, + marginBottom: 8, + }, + emptySubtext: { + fontSize: 14, + color: colors.textSecondary, + textAlign: 'center', + }, + footerContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 40, + gap: 32, + }, + footerButton: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + footerIcon: { + width: 24, + height: 24, + tintColor: '#ffffff', + }, + }); +}; + +export default ImageGalleryScreen; diff --git a/react-native/src/prompt/PromptScreen.tsx b/react-native/src/prompt/PromptScreen.tsx index a0c72335..723f9155 100644 --- a/react-native/src/prompt/PromptScreen.tsx +++ b/react-native/src/prompt/PromptScreen.tsx @@ -27,7 +27,7 @@ const MAX_NAME_LENGTH = 20; function PromptScreen(): React.JSX.Element { const navigation = useNavigation(); const route = useRoute(); - const isNovaSonic = getTextModel().modelId.includes('nova-sonic'); + const isNovaSonic = getTextModel().modelId.includes('sonic'); const isAddMode = route.params.prompt === undefined; const promptType = route.params.promptType; const { colors, isDark } = useTheme(); diff --git a/react-native/src/settings/ModelPrice.ts b/react-native/src/settings/ModelPrice.ts index fd424740..6198a2ad 100644 --- a/react-native/src/settings/ModelPrice.ts +++ b/react-native/src/settings/ModelPrice.ts @@ -114,6 +114,18 @@ export const ModelPrice: ModelPriceType = { inputTokenPrice: 0.00015, outputTokenPrice: 0.0006, }, + 'gpt-oss-20b': { + inputTokenPrice: 0.00007, + outputTokenPrice: 0.0003, + }, + 'gpt-oss-120b': { + inputTokenPrice: 0.00015, + outputTokenPrice: 0.0006, + }, + 'Minimax M2': { + inputTokenPrice: 0.0003, + outputTokenPrice: 0.0012, + }, 'Titan Text G1 - Lite': { inputTokenPrice: 0.00015, outputTokenPrice: 0.0002, @@ -134,6 +146,10 @@ export const ModelPrice: ModelPriceType = { inputTokenPrice: 0.00006, outputTokenPrice: 0.00024, }, + 'Nova 2 Lite': { + inputTokenPrice: 0.0003, + outputTokenPrice: 0.0025, + }, 'Nova Micro': { inputTokenPrice: 0.000035, outputTokenPrice: 0.00014, @@ -174,6 +190,22 @@ export const ModelPrice: ModelPriceType = { inputTokenPrice: 0.003, outputTokenPrice: 0.015, }, + 'Claude Sonnet 4': { + inputTokenPrice: 0.003, + outputTokenPrice: 0.015, + }, + 'Claude Sonnet 4.5': { + inputTokenPrice: 0.003, + outputTokenPrice: 0.015, + }, + 'Claude Opus 4.5': { + inputTokenPrice: 0.005, + outputTokenPrice: 0.025, + }, + 'Claude Haiku 4.5': { + inputTokenPrice: 0.001, + outputTokenPrice: 0.005, + }, Command: { inputTokenPrice: 0.0015, outputTokenPrice: 0.002, diff --git a/react-native/src/settings/SettingsScreen.tsx b/react-native/src/settings/SettingsScreen.tsx index c563991e..962032c1 100644 --- a/react-native/src/settings/SettingsScreen.tsx +++ b/react-native/src/settings/SettingsScreen.tsx @@ -12,6 +12,8 @@ import { TouchableOpacity, View, } from 'react-native'; +import Dialog from 'react-native-dialog'; +import RNFS from 'react-native-fs'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { setHapticFeedbackEnabled, trigger } from '../chat/util/HapticUtils.ts'; import { HapticFeedbackTypes } from 'react-native-haptic-feedback/src'; @@ -54,6 +56,9 @@ import { saveBedrockApiKey, generateOpenAICompatModels, getOpenAICompatConfigs, + getTavilyApiKey, + saveTavilyApiKey, + clearAllChatHistory, } from '../storage/StorageUtils.ts'; import { CustomHeaderRightButton } from '../chat/component/CustomHeaderRightButton.tsx'; import { RouteParamList } from '../types/RouteTypes.ts'; @@ -68,6 +73,7 @@ import { import packageJson from '../../package.json'; import { isMac } from '../App.tsx'; +import { getBuildNumber } from '../utils/PlatformUtils.ts'; import CustomDropdown from './DropdownComponent.tsx'; import { addBedrockPrefixToDeepseekModels, @@ -132,10 +138,15 @@ function SettingsScreen(): React.JSX.Element { const [bedrockConfigMode, setBedrockConfigMode] = useState(getBedrockConfigMode); const [bedrockApiKey, setBedrockApiKey] = useState(getBedrockApiKey); + const [tavilyApiKey, setTavilyApiKey] = useState(getTavilyApiKey); const { sendEvent } = useAppContext(); const sendEventRef = useRef(sendEvent); const openAICompatConfigsRef = useRef(openAICompatConfigs); const bedrockConfigModeRef = useRef(bedrockConfigMode); + const [showClearDialog, setShowClearDialog] = useState(false); + const [clearCountdown, setClearCountdown] = useState(10); + const [isClearing, setIsClearing] = useState(false); + const countdownIntervalRef = useRef(null); // Handle OpenAI Compatible configs change const handleOpenAICompatConfigsChange = useCallback( @@ -386,7 +397,7 @@ function SettingsScreen(): React.JSX.Element { })); const textModelsData: DropdownItem[] = textModels.map(model => ({ label: model.modelName ?? '', - value: model.modelId ?? '', + value: model.modelName ?? '', })); const imageModelsData: DropdownItem[] = imageModels.map(model => ({ label: model.modelName ?? '', @@ -413,6 +424,74 @@ function SettingsScreen(): React.JSX.Element { saveThinkingEnabled(value); }; + const handleOpenClearDialog = () => { + setShowClearDialog(true); + setClearCountdown(10); + countdownIntervalRef.current = setInterval(() => { + setClearCountdown(prev => { + if (prev <= 1) { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + return 0; + } + return prev - 1; + }); + }, 1000); + }; + + const handleCloseClearDialog = () => { + setShowClearDialog(false); + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + setClearCountdown(10); + }; + + const handleClearAllData = async () => { + if (clearCountdown > 0) { + return; + } + setIsClearing(true); + try { + // Clear all chat history from storage + clearAllChatHistory(); + + // Delete all files in DocumentDirectoryPath + const documentPath = RNFS.DocumentDirectoryPath; + const files = await RNFS.readDir(documentPath); + for (const file of files) { + // Skip system files and directories that shouldn't be deleted + if ( + file.name.startsWith('.') || + file.name === 'mmkv' || + file.name === 'RCTAsyncLocalStorage' || + file.name === 'RCTAsyncLocalStorage_V1' + ) { + continue; + } + try { + if (file.isDirectory()) { + await RNFS.unlink(file.path); + } else { + await RNFS.unlink(file.path); + } + } catch (e) { + console.warn('Failed to delete file:', file.path, e); + } + } + + sendEvent('historyChanged'); + handleCloseClearDialog(); + } catch (error) { + console.error('Error clearing data:', error); + } finally { + setIsClearing(false); + } + }; + const renderProviderSettings = () => { switch (selectedTab) { case 'bedrock': @@ -593,11 +672,11 @@ function SettingsScreen(): React.JSX.Element { { if (item.value !== '') { const selectedModel = textModels.find( - model => model.modelId === item.value + model => model.modelName === item.value ); if (selectedModel) { saveTextModel(selectedModel); @@ -670,6 +749,19 @@ function SettingsScreen(): React.JSX.Element { }} placeholder="Select image size" /> + + Web Search + + { + setTavilyApiKey(text); + saveTavilyApiKey(text); + }} + placeholder="Enter Tavily API Key" + secureTextEntry={true} + /> App Version {packageJson.version + - (upgradeInfo.needUpgrade - ? ' (' + upgradeInfo.version + ')' - : '')} + (Platform.OS === 'ios' && getBuildNumber() + ? ` (${getBuildNumber()})` + : '') + + (upgradeInfo.needUpgrade ? ` → ${upgradeInfo.version}` : '')} + + Clear All Chat History + + + Clear All Data + + This will delete all chat history and saved files. This action cannot + be undone. + {clearCountdown > 0 + ? `\n\nPlease wait ${clearCountdown} seconds to confirm.` + : '\n\nYou can now confirm the deletion.'} + + + 0 || isClearing} + color={clearCountdown > 0 ? '#999' : '#FF3B30'} + /> + ); } @@ -857,6 +973,20 @@ const createStyles = (colors: ColorScheme) => marginVertical: 10, paddingBottom: 60, }, + clearDataButton: { + backgroundColor: '#F5F5F5', + borderRadius: 8, + paddingVertical: 14, + alignItems: 'center', + justifyContent: 'center', + marginTop: 20, + marginBottom: 80, + }, + clearDataButtonText: { + color: '#FF3B30', + fontSize: 16, + fontWeight: '600', + }, apiKeyContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/react-native/src/storage/Constants.ts b/react-native/src/storage/Constants.ts index 282ede55..ed1d9bea 100644 --- a/react-native/src/storage/Constants.ts +++ b/react-native/src/storage/Constants.ts @@ -1,5 +1,6 @@ import { Model, ModelTag, SystemPrompt } from '../types/Chat.ts'; import { getDeepSeekApiKey, getOpenAIApiKey } from './StorageUtils.ts'; +import { isMac } from '../App.tsx'; // AWS credentials - empty by default, to be filled by user const RegionList = [ @@ -213,22 +214,9 @@ No explanation or alternatives.`, includeHistory: false, }, { - id: -2, - name: 'OptimizeCode', - prompt: `You are a code optimizer that focuses on identifying 1-3 key improvements in code snippets while maintaining core functionality. Analyze performance, readability and modern best practices. - -If no code is provided: Reply "Please share code for optimization." -If code needs improvement: Provide optimized version with 1-3 specific changes and their benefits. -If code is already optimal: Reply "Code is well written, no significant optimizations needed." - -Stay focused on practical improvements only.`, - includeHistory: false, - }, - { - id: -3, - name: 'CreateStory', - prompt: - 'You are an AI assistant with a passion for creative writing and storytelling. Your task is to collaborate with users to create engaging stories, offering imaginative plot twists and dynamic character development. Encourage the user to contribute their ideas and build upon them to create a captivating narrative.', + id: -10, + name: 'App', + prompt: '', // Dynamic prompt, will be set in getDefaultSystemPrompts() includeHistory: true, }, ...DefaultVoiceSystemPrompts, @@ -257,6 +245,56 @@ export function getDefaultImageModels() { return [DefaultImageModel] as Model[]; } +const getAppPrompt = () => { + const deviceHint = isMac + ? '' + : ` +IMPORTANT: The user is on a mobile device. You MUST: +- Design for mobile-first, responsive layout +- Use viewport meta tag with width=device-width, viewport-fit=cover +- Ensure touch-friendly UI (minimum 44px touch targets) +- Use flexible units (%, vh, vw) instead of fixed pixels +- Use env(safe-area-inset-*) for padding to avoid notch and home indicator`; + + return `You are an expert HTML/CSS/JavaScript developer. Your task is to create fully functional, interactive single-page web applications based on user requirements. + +## Output Format +1. First, output the complete HTML code wrapped in \`\`\`html code block +2. Then provide a brief introduction of the app and usage instructions + +## Code Requirements +- Generate a complete, self-contained HTML file with embedded CSS and JavaScript +- Include , , , and tags +- All styles must be in