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.

### 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
-
+
-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.

-**Rich Markdown Support**: Paragraph, Code Blocks, Tables, LaTeX and More
+**Rich Markdown Support**: Paragraph, Code Blocks, Tables, LaTeX, Mermaid and More

+
+

+

+
+
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

### 新功能 🔥
-
+- 🚀 支持网络搜索,获取实时信息(自 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
### 架构
-
-
-默认情况下,我们使用 **AWS App Runner**,它通常用于托管 Python FastAPI 服务器,提供高性能、可扩展性和低延迟。
+
-或者,我们提供用 **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

-**丰富的 Markdown 支持**:段落、代码块、表格、LaTeX 等
+**丰富的 Markdown 支持**:段落、代码块、表格、LaTeX、Mermaid 等

+
+

+

+
+
我们重新设计了 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('