diff --git a/README.md b/README.md
index d2f5e78a..34fa9fef 100644
--- a/README.md
+++ b/README.md
@@ -16,18 +16,17 @@ across Android, iOS, and macOS platforms.

### What's New 🔥
-
+- 🚀 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.
-- Support adds multiple OpenAI Compatible model providers. You can now
- use [Easy Model Deployer](https://github.com/aws-samples/easy-model-deployer), OpenRouter, or any OpenAI-compatible
- model provider. (From v2.5.0).
-- Supports dark mode on Android, iOS, and Mac (From v2.4.0).
-- Support Speech to Speech By Amazon Nova Sonic on Apple Platform. (From v2.3.0).
## 📱 Quick Download
@@ -207,6 +206,10 @@ Congratulations 🎉 Your SwiftChat App is ready to use!
- `GPT-4.1`
- `GPT-4.1 mini`
- `GPT-4.1 nano`
+ - `GPT-5`
+ - `GPT-5 chat`
+ - `GPT-5 mini`
+ - `GPT-5 nano`
Additionally, if you have deployed and configured the [SwiftChat Server](#getting-started-with-amazon-bedrock), you
can enable the **Use Proxy** option to forward your requests.
@@ -232,7 +235,7 @@ can enable the **Use Proxy** option to forward your requests.
## Key Features
- Real-time streaming chat with AI
-- Rich Markdown Support: Tables, Code Blocks, LaTeX and More
+- Rich Markdown Support: Tables, Code Blocks, LaTeX, Mermaid Chart and More
- AI image generation with progress
- Multimodal support (images, videos & documents)
- Conversation history list view and management
diff --git a/README_CN.md b/README_CN.md
index 2c10e2bd..cecaf0ba 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -16,17 +16,17 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https
### 新功能 🔥
+- 🚀 支持流式渲染 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 窗口进行并行操作。
-- 支持添加多个 OpenAI Compatible
- 模型提供商。您现在可以使用 [Easy Model Deployer](https://github.com/aws-samples/easy-model-deployer)、OpenRouter 或任何
- OpenAI 兼容的模型提供商(自 v2.5.0 起)。
-- 支持 Android、iOS 和 Mac 上的暗黑模式(自 v2.4.0 起)。
-- 在 Apple 平台上支持 Amazon Nova Sonic 语音对语音功能(自 v2.3.0 起)。
## 📱 快速下载
@@ -195,6 +195,10 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https
- `GPT-4.1`
- `GPT-4.1 mini`
- `GPT-4.1 nano`
+ - `GPT-5`
+ - `GPT-5 chat`
+ - `GPT-5 mini`
+ - `GPT-5 nano`
此外,如果您已部署并配置了 [SwiftChat 服务器](#amazon-bedrock-入门指南),可以启用 **Use Proxy** 选项来转发您的请求。
@@ -218,7 +222,7 @@ SwiftChat 是一款快速响应的 AI 聊天应用,采用 [React Native](https
## 主要功能
- 与 AI 进行实时流式聊天
-- 丰富的 Markdown 支持:表格、代码块、LaTeX 等
+- 丰富的 Markdown 支持:表格、代码块、LaTeX, Mermaid图标等
- 带进度显示的 AI 图像生成
- 多模态支持(图像、视频和文档)
- 对话历史列表查看和管理
diff --git a/assets/animations/mermaid.avif b/assets/animations/mermaid.avif
new file mode 100644
index 00000000..ed2fcbed
Binary files /dev/null and b/assets/animations/mermaid.avif differ
diff --git a/assets/animations/mermaid_en.avif b/assets/animations/mermaid_en.avif
new file mode 100644
index 00000000..8061b85a
Binary files /dev/null and b/assets/animations/mermaid_en.avif differ
diff --git a/assets/animations/mermaid_save.avif b/assets/animations/mermaid_save.avif
new file mode 100644
index 00000000..31ab008d
Binary files /dev/null and b/assets/animations/mermaid_save.avif differ
diff --git a/assets/animations/mermaid_save_en.avif b/assets/animations/mermaid_save_en.avif
new file mode 100644
index 00000000..47996d8e
Binary files /dev/null and b/assets/animations/mermaid_save_en.avif differ
diff --git a/react-native/ios/Podfile.lock b/react-native/ios/Podfile.lock
index 930f90ed..6c853b3f 100644
--- a/react-native/ios/Podfile.lock
+++ b/react-native/ios/Podfile.lock
@@ -7,9 +7,9 @@ PODS:
- hermes-engine (0.74.1):
- hermes-engine/Pre-built (= 0.74.1)
- hermes-engine/Pre-built (0.74.1)
- - MMKV (1.3.9):
- - MMKVCore (~> 1.3.9)
- - MMKVCore (1.3.9)
+ - MMKV (2.2.3):
+ - MMKVCore (~> 2.2.3)
+ - MMKVCore (2.2.3)
- RCT-Folly (2024.01.01.00):
- boost
- DoubleConversion
@@ -1027,6 +1027,27 @@ PODS:
- Yoga
- react-native-safe-area-context (4.10.8):
- React-Core
+ - react-native-webview (13.16.0):
+ - DoubleConversion
+ - glog
+ - hermes-engine
+ - RCT-Folly (= 2024.01.01.00)
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Codegen
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-rendererdebug
+ - React-utils
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - Yoga
- React-nativeconfig (0.74.1)
- React-NativeModulesApple (0.74.1):
- glog
@@ -1374,6 +1395,7 @@ DEPENDENCIES:
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
- 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`)
- React-nativeconfig (from `../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
@@ -1488,6 +1510,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-mmkv"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
+ react-native-webview:
+ :path: "../node_modules/react-native-webview"
React-nativeconfig:
:path: "../node_modules/react-native/ReactCommon"
React-NativeModulesApple:
@@ -1562,73 +1586,74 @@ SPEC CHECKSUMS:
fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
hermes-engine: 16b8530de1b383cdada1476cf52d1b52f0692cbc
- MMKV: 817ba1eea17421547e01e087285606eb270a8dcb
- MMKVCore: af055b00e27d88cd92fad301c5fecd1ff9b26dd9
- RCT-Folly: 02617c592a293bd6d418e0a88ff4ee1f88329b47
+ MMKV: 941e8774da0e6fdf12c6b3fcc833ca687ae5a42d
+ MMKVCore: 6d5cc1bacce539f4c974985dfe646fb65a5d27d2
+ RCT-Folly: 5dc73daec3476616d19e8a53f0156176f7b55461
RCTDeprecation: efb313d8126259e9294dc4ee0002f44a6f676aba
RCTRequired: f49ea29cece52aee20db633ae7edc4b271435562
RCTTypeSafety: a11979ff0570d230d74de9f604f7d19692157bc4
React: 88794fad7f460349dbc9df8a274d95f37a009f5d
React-callinvoker: 7a7023e34a55c89ea2aa62486bb3c1164ab0be0c
- React-Codegen: af31a9323ce23988c255c9afd0ae9415ff894939
- React-Core: 60075333bc22b5a793d3f62e207368b79bff2e64
- React-CoreModules: 147c314d6b3b1e069c9ad64cbbbeba604854ff86
- React-cxxreact: 5de27fd8bff4764acb2eac3ee66001e0e2b910e7
+ React-Codegen: 118828b0731a9ecf9021270b788f958f9ccb2e19
+ React-Core: 74cc07109071b230de904d394c2bf15b9f886bff
+ React-CoreModules: 8beb4863375aafeac52c49a3962b81d137577585
+ React-cxxreact: d0b0d575214ba236dff569e14dd4411ac82b3566
React-debug: 6397f0baf751b40511d01e984b01467d7e6d8127
- React-Fabric: 6fa475e16e0a37b38d462cec32b70fd5cf886305
- React-FabricImage: 7e09b3704e3fa084b4d44b5b5ef6e2e3d3334ec0
+ React-Fabric: 37f29709a9caefd2a9fece6f695bc88a0af77f40
+ React-FabricImage: 9c3f6125b2f5908a2e7d0947cfb74022c1a0b294
React-featureflags: 2eb79dd9df4095bff519379f2a4c915069e330bb
- React-graphics: 82a482a3aa5d9659b74cdf2c8b57faf67eaa10fb
- React-hermes: d93936b02de2fd7e67c11e92c16d4278a14d0134
- React-ImageManager: ebb3c4812e2c5acba5a89728c2d77729471329ad
- React-jserrorhandler: a08e0adcf1612900dde82b8bf8e93e7d2ad953b3
- React-jsi: f46d09ee5079a4f3b637d30d0e59b8ea6470632c
- React-jsiexecutor: e73579560957aa3ca9dc02ab90e163454279d48c
- React-jsinspector: e8ba20dde269c7c1d45784b858fa1cf4383f0bbb
- React-jsitracing: 233d1a798fe0ff33b8e630b8f00f62c4a8115fbc
- React-logger: 7e7403a2b14c97f847d90763af76b84b152b6fce
- React-Mapbuffer: 11029dcd47c5c9e057a4092ab9c2a8d10a496a33
- react-native-compressor: 2ae9013718fb351264fcfcdf232eccbbf3d280a2
- react-native-document-picker: c4f197741c327270453aa9840932098e0064fd52
- react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06
- react-native-image-picker: fb0c2b3adc3eff6caa3cd6a507a34b9dcc9238dd
- react-native-mmkv: 8c9a677e64a1ac89b0c6cf240feea528318b3074
- react-native-safe-area-context: b7daa1a8df36095a032dff095a1ea8963cb48371
+ React-graphics: d0b9a0a174fb86bfed50bf4fb7c835183a546ab5
+ React-hermes: 06e8316213d56ab914afb9a829763123fcfacf22
+ React-ImageManager: 821a1182139cc986598868d0e9a00b3a021feddb
+ React-jserrorhandler: 1dd2a75b24dd9a318ee88fa6792e98524879af24
+ React-jsi: e381545475da5ea77777e7b5513031a434ced04b
+ React-jsiexecutor: ce91dde1a61efd519a5ff7ac0f64b61a14217072
+ React-jsinspector: 627ac44b1d090fc6a8039b1df723677bc7d86fe4
+ React-jsitracing: dd0e541a34027b3ab668ad94cf268482ad6f82fb
+ React-logger: 6070f362a1657bb53335eb1fc903d3f49fd79842
+ React-Mapbuffer: 2c95cbabc3d75a17747452381e998c35208ea3ee
+ react-native-compressor: 837b2774cb6e6a026862d90a783586ca317c29c3
+ react-native-document-picker: 451699da81cba8b40b596b8076019a4deb86f46e
+ react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
+ react-native-image-picker: f1006d8935a3bc0baf8157faaa7857c76a77c8bb
+ react-native-mmkv: f8155c2efbe795cb0c7586d00ff484b1c9388af0
+ react-native-safe-area-context: b72c4611af2e86d80a59ac76279043d8f75f454c
+ react-native-webview: b836f1f162b87b5b8351611b5d5299f2b699360a
React-nativeconfig: b0073a590774e8b35192fead188a36d1dca23dec
- React-NativeModulesApple: df46ff3e3de5b842b30b4ca8a6caae6d7c8ab09f
+ React-NativeModulesApple: 61b07ab32af3ea4910ba553932c0a779e853c082
React-perflogger: 3d31e0d1e8ad891e43a09ac70b7b17a79773003a
React-RCTActionSheet: c4a3a134f3434c9d7b0c1054f1a8cfed30c7a093
- React-RCTAnimation: 0e5d15320eeece667fcceb6c785acf9a184e9da1
- React-RCTAppDelegate: c4f6c0700b8950a8b18c2e004996eec1807d430a
- React-RCTBlob: c46aaaee693d371a1c7cae2a8c8ee2aa7fbc1adb
- React-RCTFabric: 0dbf28ce96c7f2843483e32a725a5b5793584ff3
- React-RCTImage: a04dba5fcc823244f5822192c130ecf09623a57f
- React-RCTLinking: 533bf13c745fcb2a0c14e0e49fd149586a7f0d14
- React-RCTNetwork: a29e371e0d363d7b4c10ab907bc4d6ae610541e9
- React-RCTSettings: 127813224780861d0d30ecda17a40d1dfebe7d73
- React-RCTText: 8a823f245ecf82edb7569646e3c4d8041deb800a
- React-RCTVibration: 46b5fae74e63f240f22f39de16ad6433da3b65d9
- React-rendererdebug: 4653f8da6ab1d7b01af796bdf8ca47a927539e39
+ React-RCTAnimation: dab04683056694845eb7a9e283f4c63eec7fa633
+ React-RCTAppDelegate: 1785d42459138c45175b2fa18e86cd2aee829a93
+ React-RCTBlob: a0a8f6bfd8926bff0e2814ec3f8cd5514f2db243
+ React-RCTFabric: f69d856b74b6d385c4cf4bd128c330161ce18306
+ React-RCTImage: 51db983bcc5075fa9bf3e094e5c6c1f5b5575472
+ React-RCTLinking: 3430cd1023a5ac86a96ed6d4fbf7a8ed7b2e44d5
+ React-RCTNetwork: 52198f8a8c823639dcc8f6725ca5b360d66ea1a0
+ React-RCTSettings: c127440c2c538128f92fb45524e976e25cb69bd6
+ React-RCTText: 640b2d0bfb51d88d8a76c6a1a7ea1f94667bf231
+ React-RCTVibration: bd20c8156b649cd745c70db3341c409ae3b42821
+ React-rendererdebug: 16394ffe0d852967123b3b76a630233b90ec8e63
React-rncore: 4f1e645acb5107bd4b4cf29eff17b04a7cd422f3
- React-RuntimeApple: 013b606e743efb5ee14ef03c32379b78bfe74354
- React-RuntimeCore: 7205be45a25713b5418bbf2db91ddfcca0761d8b
+ React-RuntimeApple: 97d0a5c655467c57b88076434427ec32413e7802
+ React-RuntimeCore: a55443ddb73e6666b441963d8951a16ba5cfc223
React-runtimeexecutor: a278d4249921853d4a3f24e4d6e0ff30688f3c16
- React-RuntimeHermes: 44c628568ce8feedc3acfbd48fc07b7f0f6d2731
- React-runtimescheduler: e2152ed146b6a35c07386fc2ac4827b27e6aad12
- React-utils: 3285151c9d1e3a28a9586571fc81d521678c196d
- ReactCommon: f42444e384d82ab89184aed5d6f3142748b54768
- RNCClipboard: 0a720adef5ec193aa0e3de24c3977222c7e52a37
- RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
- RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
- RNGestureHandler: 8dbcccada4a7e702e7dec9338c251b1cf393c960
- RNReactNativeHapticFeedback: ec56a5f81c3941206fd85625fa669ffc7b4545f9
- RNReanimated: f4ff116e33e0afc3d127f70efe928847c7c66355
- RNScreens: 024c5cb8569dea6667b5c73e6c119beb83f686f0
- RNShare: 0fad69ae2d71de9d1f7b9a43acf876886a6cb99c
- RNSVG: 7ff26379b2d1871b8571e6f9bc9630de6baf9bdf
+ React-RuntimeHermes: 6273f0755fef304453fc3c356b25abf17e915b83
+ React-runtimescheduler: 87b14969bb0b10538014fb8407d472f9904bc8cd
+ React-utils: 67574b07bff4429fd6c4d43a7fad8254d814ee20
+ ReactCommon: 64c64f4ae1f2debe3fab1800e00cb8466a4477b7
+ RNCClipboard: 4598dae0fe33e2aa130d9d213e2007be78310266
+ RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
+ RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
+ RNGestureHandler: 77ca8899a0bd9a9d948e74174ee401bbffa5e524
+ RNReactNativeHapticFeedback: a6fb5b7a981683bf58af43e3fb827d4b7ed87f83
+ RNReanimated: fe62058b0e1ecb46e252d63d27580f36cd6d9eb2
+ RNScreens: df14a2a11e7afb57e6f35f8964d206271f4dae44
+ RNShare: 694e19d7f74ac4c04de3a8af0649e9ccc03bd8b1
+ RNSVG: 3421710ac15f4f2dc47e5c122f2c2e4282116830
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
- Yoga: b9a182ab00cf25926e7f79657d08c5d23c2d03b0
+ Yoga: 348f8b538c3ed4423eb58a8e5730feec50bce372
-PODFILE CHECKSUM: ccd372319eaeea775147eb2843bb9cf9d1b4cd7a
+PODFILE CHECKSUM: eed3e49f72b6465b4551e72df9de6b83230c91ca
-COCOAPODS: 1.14.3
+COCOAPODS: 1.16.2
diff --git a/react-native/package-lock.json b/react-native/package-lock.json
index 4f1f7a6f..bb503377 100644
--- a/react-native/package-lock.json
+++ b/react-native/package-lock.json
@@ -42,6 +42,7 @@
"react-native-share": "^10.2.1",
"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"
},
"devDependencies": {
@@ -13220,6 +13221,32 @@
"react-native": "*"
}
},
+ "node_modules/react-native-webview": {
+ "version": "13.16.0",
+ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz",
+ "integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "invariant": "2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-webview/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/react-native-windows": {
"version": "0.74.21",
"resolved": "https://registry.npmjs.org/react-native-windows/-/react-native-windows-0.74.21.tgz",
@@ -25839,6 +25866,22 @@
"whatwg-url-without-unicode": "8.0.0-3"
}
},
+ "react-native-webview": {
+ "version": "13.16.0",
+ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.0.tgz",
+ "integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==",
+ "requires": {
+ "escape-string-regexp": "^4.0.0",
+ "invariant": "2.2.4"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
+ }
+ }
+ },
"react-native-windows": {
"version": "0.74.21",
"resolved": "https://registry.npmjs.org/react-native-windows/-/react-native-windows-0.74.21.tgz",
diff --git a/react-native/package.json b/react-native/package.json
index 28c222a1..86c6b07d 100644
--- a/react-native/package.json
+++ b/react-native/package.json
@@ -46,6 +46,7 @@
"react-native-share": "^10.2.1",
"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"
},
"devDependencies": {
diff --git a/react-native/src/assets/download.png b/react-native/src/assets/download.png
new file mode 100644
index 00000000..608252e0
Binary files /dev/null and b/react-native/src/assets/download.png differ
diff --git a/react-native/src/chat/ChatScreen.tsx b/react-native/src/chat/ChatScreen.tsx
index 2bb8868e..a5720509 100644
--- a/react-native/src/chat/ChatScreen.tsx
+++ b/react-native/src/chat/ChatScreen.tsx
@@ -410,6 +410,10 @@ function ChatScreen(): React.JSX.Element {
});
}, 100);
}
+ // Notify Mermaid renderers to refresh after streaming completes
+ setTimeout(() => {
+ sendEventRef.current('refreshMermaid');
+ }, 150);
setChatStatus(ChatStatus.Init);
}
}, [chatStatus]);
diff --git a/react-native/src/chat/component/markdown/CustomCodeHighlighter.tsx b/react-native/src/chat/component/markdown/CustomCodeHighlighter.tsx
index 085d6527..8247c6be 100644
--- a/react-native/src/chat/component/markdown/CustomCodeHighlighter.tsx
+++ b/react-native/src/chat/component/markdown/CustomCodeHighlighter.tsx
@@ -159,7 +159,8 @@ export const CustomCodeHighlighter: FunctionComponent = ({
const renderNode = useCallback(
(nodes: rendererNode[]): ReactNode => {
// Calculate margin bottom value once
- const marginBottomValue = -nodes.length * (isMac ? 3 : 2.75);
+ const scale = rest.language === 'mermaid' ? 1.75 : isMac ? 3 : 2.75;
+ const marginBottomValue = -nodes.length * scale;
// Optimization for streaming content - only process new nodes
if (nodes.length >= prevNodesLength.current) {
@@ -200,7 +201,7 @@ export const CustomCodeHighlighter: FunctionComponent = ({
);
},
- [processNode]
+ [processNode, rest.language]
);
const renderAndroidNode = useCallback(
diff --git a/react-native/src/chat/component/markdown/CustomMarkdownRenderer.tsx b/react-native/src/chat/component/markdown/CustomMarkdownRenderer.tsx
index 47b90ab6..75c477ab 100644
--- a/react-native/src/chat/component/markdown/CustomMarkdownRenderer.tsx
+++ b/react-native/src/chat/component/markdown/CustomMarkdownRenderer.tsx
@@ -40,6 +40,7 @@ import Disc from '@jsamr/counter-style/lib/es/presets/disc';
import MathView from 'react-native-math-view';
import { isAndroid } from '../../../utils/PlatformUtils.ts';
import { ColorScheme } from '../../../theme';
+import MermaidCodeRenderer from './MermaidCodeRenderer';
const CustomCodeHighlighter = lazy(() => import('./CustomCodeHighlighter'));
let mathViewIndex = 0;
@@ -107,11 +108,25 @@ const MemoizedCodeHighlighter = React.memo(
isDark: boolean;
}) => {
const styles = createCustomStyles(colors);
+ // Use useRef to always capture the latest text value
+ const textRef = React.useRef(text);
+ textRef.current = text;
+
const handleCopy = useCallback(() => {
- Clipboard.setString(text);
- }, [text]);
+ Clipboard.setString(textRef.current);
+ }, []);
const hljsStyle = isDark ? vs2015 : github;
+ if (language === 'mermaid') {
+ return (
+
+ );
+ }
return (
@@ -144,11 +159,17 @@ const MemoizedCodeHighlighter = React.memo(
);
},
- (prevProps, nextProps) =>
- prevProps.text === nextProps.text &&
- prevProps.language === nextProps.language &&
- prevProps.colors === nextProps.colors &&
- prevProps.isDark === nextProps.isDark
+ (prevProps, nextProps) => {
+ if (prevProps.language === 'mermaid' || nextProps.language === 'mermaid') {
+ return false;
+ }
+ return (
+ prevProps.text === nextProps.text &&
+ prevProps.language === nextProps.language &&
+ prevProps.colors === nextProps.colors &&
+ prevProps.isDark === nextProps.isDark
+ );
+ }
);
export class CustomMarkdownRenderer
@@ -281,9 +302,11 @@ export class CustomMarkdownRenderer
_textStyle?: TextStyle
): ReactNode {
if (text && text !== '') {
+ const componentKey =
+ language === 'mermaid' ? 'mermaid-code-block' : this.getKey();
return (
import('./CustomCodeHighlighter')
+);
+
+interface MermaidCodeRendererProps {
+ text: string;
+ colors: ColorScheme;
+ isDark: boolean;
+ onCopy: () => void;
+}
+
+interface MermaidCodeRendererRef {
+ updateContent: (newText: string) => void;
+}
+
+interface MermaidRendererRef {
+ updateContent: (newCode: string) => void;
+}
+
+const MermaidCodeRenderer = forwardRef<
+ MermaidCodeRendererRef,
+ MermaidCodeRendererProps
+>(({ text, colors, isDark, onCopy }, ref) => {
+ const [showCode, setShowCode] = useState(false);
+ const [showNewMermaid, setShowNewMermaid] = useState(false);
+ const [currentText, setCurrentText] = useState(text);
+ const { event } = useAppContext();
+ const mermaidRendererRef = useRef(null);
+ const styles = createStyles(colors);
+ const hljsStyle = isDark ? vs2015 : github;
+
+ const updateContent = useCallback(
+ (newText: string) => {
+ setCurrentText(newText);
+ if (!showCode && mermaidRendererRef.current) {
+ mermaidRendererRef.current.updateContent(newText);
+ }
+ },
+ [showCode]
+ );
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ updateContent,
+ }),
+ [updateContent]
+ );
+
+ useEffect(() => {
+ setCurrentText(text);
+ }, [text]);
+
+ const setMermaidMode = () => {
+ setShowCode(false);
+ };
+
+ const setCodeMode = () => {
+ setShowCode(true);
+ };
+
+ // Listen for refresh event from ChatScreen and update key to remount WebView
+ useEffect(() => {
+ if (event?.event === 'refreshMermaid') {
+ setShowNewMermaid(true);
+ }
+ }, [event]);
+
+ return (
+
+
+
+
+
+
+ mermaid
+
+
+
+
+ code
+
+
+
+
+
+
+
+ {showCode ? (
+ Loading...}>
+
+ {currentText}
+
+
+ ) : (
+
+ )}
+
+ );
+});
+
+const createStyles = (colors: ColorScheme) =>
+ StyleSheet.create({
+ container: {
+ borderRadius: 8,
+ overflow: 'hidden',
+ backgroundColor: colors.input,
+ marginVertical: 6,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: colors.borderLight,
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
+ paddingVertical: 2,
+ paddingHorizontal: 4,
+ },
+ leftSection: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ tabContainer: {
+ flexDirection: 'row',
+ backgroundColor: colors.input,
+ borderRadius: 6,
+ padding: 2,
+ },
+ tabButton: {
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 4,
+ marginHorizontal: 1,
+ },
+ activeTab: {
+ backgroundColor: colors.text,
+ },
+ tabText: {
+ fontSize: 14,
+ color: colors.text,
+ fontWeight: '500',
+ opacity: 0.6,
+ },
+ activeTabText: {
+ color: colors.background,
+ opacity: 1,
+ },
+ loading: {
+ padding: 12,
+ color: colors.text,
+ },
+ codeText: {
+ fontSize: 14,
+ paddingVertical: 1.3,
+ fontFamily: Platform.OS === 'ios' ? 'Menlo-Regular' : 'monospace',
+ color: colors.text,
+ },
+ mermaidRenderer: {
+ marginVertical: 0,
+ minHeight: 100,
+ },
+ });
+
+export default MermaidCodeRenderer;
diff --git a/react-native/src/chat/component/markdown/MermaidFullScreenViewer.tsx b/react-native/src/chat/component/markdown/MermaidFullScreenViewer.tsx
new file mode 100644
index 00000000..37061fcc
--- /dev/null
+++ b/react-native/src/chat/component/markdown/MermaidFullScreenViewer.tsx
@@ -0,0 +1,689 @@
+import React, {
+ useMemo,
+ useState,
+ useRef,
+ useCallback,
+ useEffect,
+} from 'react';
+import {
+ Modal,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+ Alert,
+ StatusBar,
+ Platform,
+ Image,
+ Dimensions,
+} from 'react-native';
+import { WebView, WebViewMessageEvent } from 'react-native-webview';
+import {
+ PanGestureHandler,
+ PinchGestureHandler,
+ PanGestureHandlerGestureEvent,
+ PinchGestureHandlerGestureEvent,
+} from 'react-native-gesture-handler';
+import Animated, {
+ useAnimatedGestureHandler,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+import RNFS from 'react-native-fs';
+import Share from 'react-native-share';
+import Clipboard from '@react-native-clipboard/clipboard';
+import { useTheme } from '../../../theme';
+import { isMac } from '../../../App.tsx';
+
+interface MermaidFullScreenViewerProps {
+ visible: boolean;
+ onClose: () => void;
+ code: string;
+}
+
+const MermaidFullScreenViewer: React.FC = ({
+ visible,
+ onClose,
+ code,
+}) => {
+ const { colors, isDark } = useTheme();
+ const webViewRef = useRef(null);
+ const [hasError, setHasError] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [screenData, setScreenData] = useState(Dimensions.get('window'));
+ const [isLandscape, setIsLandscape] = useState(
+ isMac ? false : screenData.width > screenData.height
+ );
+
+ // Animation values for pan and zoom
+ const translateX = useSharedValue(0);
+ const translateY = useSharedValue(0);
+ const scale = useSharedValue(1);
+ const baseScale = useSharedValue(1);
+ const savedTranslateX = useSharedValue(0);
+ const savedTranslateY = useSharedValue(0);
+
+ // Listen for orientation changes
+ useEffect(() => {
+ const subscription = Dimensions.addEventListener('change', ({ window }) => {
+ setScreenData(window);
+ setIsLandscape(isMac ? true : window.width > window.height);
+ });
+
+ return () => subscription?.remove();
+ }, []);
+
+ // Reset copied state after 2 seconds
+ useEffect(() => {
+ if (copied) {
+ const timer = setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [copied]);
+
+ // Reset transforms when modal opens
+ useEffect(() => {
+ if (visible) {
+ translateX.value = 0;
+ translateY.value = 0;
+ scale.value = 1;
+ baseScale.value = 1;
+ savedTranslateX.value = 0;
+ savedTranslateY.value = 0;
+ setHasError(false);
+ }
+ }, [
+ visible,
+ translateX,
+ translateY,
+ scale,
+ baseScale,
+ savedTranslateX,
+ savedTranslateY,
+ ]);
+
+ const pinchHandler =
+ useAnimatedGestureHandler({
+ onStart: () => {
+ baseScale.value = scale.value;
+ },
+ onActive: event => {
+ scale.value = Math.max(0.5, Math.min(baseScale.value * event.scale, 5));
+ },
+ onEnd: () => {
+ if (scale.value < 1) {
+ scale.value = withSpring(1);
+ translateX.value = withSpring(0);
+ translateY.value = withSpring(0);
+ }
+ },
+ });
+
+ const panHandler = useAnimatedGestureHandler({
+ onStart: () => {
+ savedTranslateX.value = translateX.value;
+ savedTranslateY.value = translateY.value;
+ },
+ onActive: event => {
+ translateX.value = savedTranslateX.value + event.translationX;
+ translateY.value = savedTranslateY.value + event.translationY;
+ },
+ onEnd: () => {
+ // Only spring back to center if scale is 1 or less
+ if (scale.value <= 1) {
+ translateX.value = withSpring(0);
+ translateY.value = withSpring(0);
+ savedTranslateX.value = 0;
+ savedTranslateY.value = 0;
+ } else {
+ // Save the final position for next pan
+ savedTranslateX.value = translateX.value;
+ savedTranslateY.value = translateY.value;
+ }
+ },
+ });
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ transform: [
+ { translateX: translateX.value },
+ { translateY: translateY.value },
+ { scale: scale.value },
+ ],
+ };
+ });
+
+ const captureImageJS = useCallback(() => {
+ return `
+ (function() {
+ try {
+ const svg = document.querySelector('#mermaid-display svg');
+ if (!svg) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({
+ type: 'capture_error',
+ message: 'No SVG found'
+ }));
+ return;
+ }
+
+ // Clone the SVG to avoid modifying the original
+ const svgClone = svg.cloneNode(true);
+
+ // Get the actual SVG dimensions from viewBox or computed values
+ let svgWidth, svgHeight;
+ const viewBox = svg.getAttribute('viewBox');
+
+ if (viewBox) {
+ // Use viewBox dimensions if available
+ const [x, y, width, height] = viewBox.split(' ').map(Number);
+ svgWidth = width;
+ svgHeight = height;
+ svgClone.setAttribute('width', width);
+ svgClone.setAttribute('height', height);
+ } else {
+ // Fallback to intrinsic dimensions
+ svgWidth = svg.scrollWidth || svg.clientWidth || parseFloat(svg.getAttribute('width')) || 800;
+ svgHeight = svg.scrollHeight || svg.clientHeight || parseFloat(svg.getAttribute('height')) || 600;
+ svgClone.setAttribute('width', svgWidth);
+ svgClone.setAttribute('height', svgHeight);
+ }
+
+ // Ensure the SVG has proper styling for export
+ svgClone.style.background = '${isDark ? '#1a1a1a' : '#ffffff'}';
+ svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
+ svgClone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
+
+ // Serialize the complete SVG
+ const svgData = new XMLSerializer().serializeToString(svgClone);
+
+ // Create canvas with actual SVG dimensions at higher resolution
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ const scale = 2; // Higher resolution multiplier
+
+ canvas.width = svgWidth * scale;
+ canvas.height = svgHeight * scale;
+
+ const img = new Image();
+ img.onload = function() {
+ try {
+ // Scale context for higher resolution
+ ctx.scale(scale, scale);
+
+ // Fill background
+ ctx.fillStyle = '${isDark ? '#1a1a1a' : '#ffffff'}';
+ ctx.fillRect(0, 0, svgWidth, svgHeight);
+
+ // Draw the complete SVG
+ ctx.drawImage(img, 0, 0, svgWidth, svgHeight);
+
+ const dataURL = canvas.toDataURL('image/png', 0.95);
+ window.ReactNativeWebView.postMessage(JSON.stringify({
+ type: 'capture_success',
+ data: dataURL
+ }));
+ } catch (error) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({
+ type: 'capture_error',
+ message: 'Canvas operation failed: ' + error.message
+ }));
+ }
+ };
+
+ img.onerror = function(error) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({
+ type: 'capture_error',
+ message: 'Failed to load image: ' + error
+ }));
+ };
+
+ // Use Data URL instead of Blob URL to avoid security issues
+ const svgDataUrl = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
+ img.src = svgDataUrl;
+
+ } catch (error) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({
+ type: 'capture_error',
+ message: error.message
+ }));
+ }
+ })();
+ true;
+ `;
+ }, [isDark]);
+
+ const copyImage = useCallback(async () => {
+ if (!webViewRef.current) {
+ return;
+ }
+
+ try {
+ const copyJS = captureImageJS().replace(
+ "'capture_success'",
+ "'copy_success'"
+ );
+ webViewRef.current.injectJavaScript(copyJS);
+ } catch (error) {
+ Alert.alert('Error', 'Failed to copy image');
+ }
+ }, [captureImageJS]);
+
+ const saveImage = useCallback(async () => {
+ if (!webViewRef.current) {
+ return;
+ }
+
+ try {
+ const saveJS = captureImageJS().replace(
+ "'capture_success'",
+ "'save_success'"
+ );
+ webViewRef.current.injectJavaScript(saveJS);
+ } catch (error) {
+ Alert.alert('Error', 'Failed to capture image');
+ }
+ }, [captureImageJS]);
+
+ // Memoize the copy icon source to prevent flickering
+ const copyIconSource = useMemo(() => {
+ return copied
+ ? isDark
+ ? require('../../../assets/done_dark.png')
+ : require('../../../assets/done.png')
+ : require('../../../assets/copy_grey.png');
+ }, [copied, isDark]);
+
+ const handleWebViewMessage = useCallback(
+ async (event: WebViewMessageEvent) => {
+ try {
+ const message = JSON.parse(event.nativeEvent.data);
+
+ if (message.type === 'copy_success') {
+ const base64Data = message.data.replace(
+ /^data:image\/png;base64,/,
+ ''
+ );
+ // Copy image to clipboard (mac only)
+ try {
+ Clipboard.setImage(base64Data);
+ setCopied(true);
+ } catch (clipboardError) {
+ console.log(
+ '[MermaidFullScreenViewer] Clipboard error:',
+ clipboardError
+ );
+ Alert.alert('Error', 'Failed to copy image to clipboard');
+ }
+ } else if (message.type === 'save_success') {
+ const base64Data = message.data.replace(
+ /^data:image\/png;base64,/,
+ ''
+ );
+ const fileName = `mermaid_diagram_${Date.now()}.png`;
+ let filePath = `${RNFS.DocumentDirectoryPath}/${fileName}`;
+
+ await RNFS.writeFile(filePath, base64Data, 'base64');
+ if (Platform.OS === 'android') {
+ filePath = 'file://' + filePath;
+ }
+ const shareOptions = {
+ url: filePath,
+ type: 'image/png',
+ title: 'Save Mermaid Diagram',
+ };
+ await Share.open(shareOptions);
+ } else if (message.type === 'capture_error') {
+ Alert.alert('Error', `Failed to capture image: ${message.message}`);
+ } else if (message.type === 'rendered') {
+ setHasError(!message.success);
+ }
+ } catch (error) {
+ console.log('[MermaidFullScreenViewer] Message parse error:', error);
+ }
+ },
+ []
+ );
+
+ const htmlContent = useMemo(() => {
+ return `
+
+
+
+
+
+
+
+
+
+ ${code}
+
+
+ Invalid Mermaid syntax
+
+
+
+`;
+ }, [code, colors.text, isDark, isLandscape]);
+
+ const styles = StyleSheet.create({
+ modal: {
+ flex: 1,
+ backgroundColor: isDark ? '#000000' : '#ffffff',
+ },
+ closeButtonTopLeft: {
+ position: 'absolute',
+ top:
+ Platform.OS === 'ios'
+ ? isLandscape
+ ? 40
+ : 60
+ : (StatusBar.currentHeight || 20) + (isLandscape ? 10 : 20),
+ left: isLandscape ? 40 : 20,
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: 'rgba(50, 50, 50, 0.8)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 1000,
+ },
+ closeButtonX: {
+ fontSize: 20,
+ fontWeight: '400',
+ marginBottom: -2,
+ color: '#ffffff',
+ lineHeight: 20,
+ },
+ copyButtonBottomRight: {
+ position: 'absolute',
+ bottom: isLandscape ? 80 : 100,
+ right: isLandscape ? 40 : 20,
+ width: 48,
+ height: 48,
+ borderRadius: 12,
+ backgroundColor: 'rgba(50, 50, 50, 0.8)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 1000,
+ },
+ saveButtonBottomRight: {
+ position: 'absolute',
+ bottom: isLandscape ? 20 : 40,
+ right: isLandscape ? 40 : 20,
+ width: 48,
+ height: 48,
+ borderRadius: 12,
+ backgroundColor: 'rgba(50, 50, 50, 0.8)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 1000,
+ },
+ saveIcon: {
+ width: 24,
+ height: 24,
+ tintColor: '#ffffff',
+ },
+ copyIcon: {
+ width: 18,
+ height: 18,
+ tintColor: '#ffffff',
+ },
+ webViewContainer: {
+ flex: 1,
+ backgroundColor: isDark ? '#1a1a1a' : '#ffffff',
+ },
+ webView: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ },
+ loadingContainer: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: isDark ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,1)',
+ zIndex: 998,
+ },
+ loadingText: {
+ marginTop: 10,
+ fontSize: 16,
+ color: colors.text,
+ },
+ });
+
+ if (!visible) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {/* Close button in top-left */}
+
+ ×
+
+
+ {/* WebView with gesture handling */}
+
+
+
+
+
+
+
+
+
+
+ {/* Copy button in bottom-right (above save button) */}
+ {isMac && (
+
+
+
+ )}
+
+ {/* Save button in bottom-right */}
+
+
+
+
+ {/* Error overlay */}
+ {hasError && (
+
+ {'Invalid Mermaid syntax'}
+
+ )}
+
+
+ );
+};
+
+export default MermaidFullScreenViewer;
diff --git a/react-native/src/chat/component/markdown/MermaidRenderer.tsx b/react-native/src/chat/component/markdown/MermaidRenderer.tsx
new file mode 100644
index 00000000..24463f63
--- /dev/null
+++ b/react-native/src/chat/component/markdown/MermaidRenderer.tsx
@@ -0,0 +1,422 @@
+import React, {
+ useMemo,
+ useState,
+ useRef,
+ useCallback,
+ forwardRef,
+ useImperativeHandle,
+ useEffect,
+} from 'react';
+import { WebView, WebViewMessageEvent } from 'react-native-webview';
+import {
+ ViewStyle,
+ TouchableOpacity,
+ View,
+ Text,
+ StyleSheet,
+} from 'react-native';
+import { ColorScheme, useTheme } from '../../../theme';
+import MermaidFullScreenViewer from './MermaidFullScreenViewer';
+
+interface MermaidRendererProps {
+ code: string;
+ style?: ViewStyle;
+}
+
+interface MermaidRendererRef {
+ updateContent: (newCode: string) => void;
+}
+
+/**
+ * Validates and filters Gantt chart code lines.
+ * For Gantt charts, checks the last line:
+ * - If the last line doesn't start with a digit, removes it
+ * - If the last line starts with a digit, must contain both colon and comma, and end with a digit
+ * - Otherwise returns original code
+ */
+const validateGanttChartCode = (code: string): string => {
+ const isGantt = code.startsWith('gantt');
+ if (!isGantt) {
+ return code;
+ }
+
+ const lines = code.split('\n');
+ if (lines.length < 2) {
+ return code;
+ }
+
+ const lastLine = lines[lines.length - 1];
+ const trimmedLast = lastLine.trim();
+
+ // If last line is empty, keep it
+ if (trimmedLast === '') {
+ return code;
+ }
+
+ // Check if last line starts with a digit
+ if (/^\d/.test(trimmedLast)) {
+ // Must contain both colon and comma, and end with a digit
+ const hasColon = trimmedLast.includes(':');
+ const hasComma = trimmedLast.includes(',');
+ const endsWithDigit = /\d$/.test(trimmedLast);
+
+ if (hasColon && hasComma && endsWithDigit) {
+ return code; // Valid, return original code
+ } else {
+ // Invalid, remove last line
+ return lines.slice(0, -1).join('\n');
+ }
+ } else {
+ // Last line doesn't start with a digit, remove it
+ return lines.slice(0, -1).join('\n');
+ }
+};
+
+const MermaidRenderer = forwardRef(
+ ({ code, style }, ref) => {
+ const validatedCode = useMemo(() => validateGanttChartCode(code), [code]);
+ const [currentCode, setCurrentCode] = useState(validatedCode);
+ const [showFullScreen, setShowFullScreen] = useState(false);
+ const [hasError, setHasError] = useState(false);
+ const webViewRef = useRef(null);
+ const initialCodeRef = useRef(validatedCode);
+ const { isDark, colors } = useTheme();
+ const styles = createStyles(colors);
+
+ const updateContent = useCallback(
+ (newCode: string) => {
+ const validatedNewCode = validateGanttChartCode(newCode);
+ if (validatedNewCode === currentCode) {
+ return;
+ }
+ if (webViewRef.current) {
+ const escapedCode = validatedNewCode
+ .replace(/`/g, '\\`')
+ .replace(/\$/g, '\\$');
+ const jsCode = `
+ (function() {
+ try {
+ const container = document.getElementById('mermaid-container');
+ const displayContainer = document.getElementById('mermaid-display');
+ if (!container || !displayContainer) return;
+
+ const newCodeContent = \`${escapedCode}\`;
+
+ // Store the new code in a hidden container
+ container.textContent = newCodeContent;
+ container.style.display = 'none';
+
+ // Try to parse and validate
+ window.mermaid.parse(newCodeContent, { suppressErrors: true })
+ .then((result) => {
+ if (result) {
+ // Valid syntax, try to render
+ return window.mermaid.render('mermaid-graph', newCodeContent);
+ } else {
+ // Don't update display, schedule error notification after 1 second
+ if (window.notifyRN) {
+ window.notifyRN('update_rendered', { success: false }, 1000);
+ }
+ // Terminate the promise chain
+ return Promise.reject(new Error('Parse failed'));
+ }
+ })
+ .then((result) => {
+ // Rendering successful, update display
+ if (result && result.svg) {
+ displayContainer.innerHTML = result.svg;
+ window.lastValidCode = newCodeContent;
+ if (window.notifyRN) {
+ // Cancel any pending error notifications and immediately notify success
+ window.notifyRN('update_rendered', { success: true }, 0);
+ }
+ }
+ })
+ .catch((error) => {
+ // Schedule error notification after 1 second
+ if (window.notifyRN) {
+ window.notifyRN('update_rendered', { success: false, error: error.message }, 1000);
+ }
+ });
+ } catch (error) {
+ // Don't update display, schedule error notification after 1 second
+ if (window.notifyRN) {
+ window.notifyRN('update_rendered', { success: false, error: error.message }, 1000);
+ }
+ }
+ })();
+ true;
+ `;
+
+ webViewRef.current.injectJavaScript(jsCode);
+ }
+
+ setCurrentCode(validatedNewCode);
+ },
+ [currentCode]
+ );
+
+ useEffect(() => {
+ if (code !== currentCode) {
+ updateContent(code);
+ }
+ }, [code, currentCode, updateContent]);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ updateContent,
+ }),
+ [updateContent]
+ );
+
+ const htmlContent = useMemo(() => {
+ return `
+
+
+
+
+
+
+
+
+
+ ${initialCodeRef.current}
+
+
+
+
+
+`;
+ }, [isDark]);
+
+ const handleMessage = useCallback((event: WebViewMessageEvent) => {
+ try {
+ const message = JSON.parse(event.nativeEvent.data);
+
+ // Handle console logs from WebView
+ if (message.type === 'console_log') {
+ console.log('[WebView]', message.message);
+ return;
+ }
+
+ if (message.type === 'rendered' || message.type === 'update_rendered') {
+ setHasError(!message.success);
+ }
+ } catch (error) {
+ console.log('[WebView] Raw message:', event.nativeEvent.data);
+ }
+ }, []);
+
+ return (
+ <>
+ setShowFullScreen(true)}
+ activeOpacity={0.8}
+ style={styles.container}>
+
+
+ {hasError && (
+
+ {'Invalid Mermaid syntax'}
+
+ )}
+
+
+ setShowFullScreen(false)}
+ code={currentCode}
+ />
+ >
+ );
+ }
+);
+
+const createStyles = (colors: ColorScheme) =>
+ StyleSheet.create({
+ container: {
+ position: 'relative' as const,
+ },
+ webView: {
+ height: 380,
+ backgroundColor: 'transparent' as const,
+ },
+ loadingContainer: {
+ position: 'absolute' as const,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ justifyContent: 'center' as const,
+ alignItems: 'center' as const,
+ backgroundColor: colors.input,
+ },
+ loadingText: {
+ marginTop: 10,
+ fontSize: 14,
+ color: colors.text,
+ },
+ });
+
+export default MermaidRenderer;
diff --git a/react-native/src/settings/SettingsScreen.tsx b/react-native/src/settings/SettingsScreen.tsx
index 5afc0729..c563991e 100644
--- a/react-native/src/settings/SettingsScreen.tsx
+++ b/react-native/src/settings/SettingsScreen.tsx
@@ -135,6 +135,7 @@ function SettingsScreen(): React.JSX.Element {
const { sendEvent } = useAppContext();
const sendEventRef = useRef(sendEvent);
const openAICompatConfigsRef = useRef(openAICompatConfigs);
+ const bedrockConfigModeRef = useRef(bedrockConfigMode);
// Handle OpenAI Compatible configs change
const handleOpenAICompatConfigsChange = useCallback(
@@ -166,7 +167,7 @@ function SettingsScreen(): React.JSX.Element {
};
if (shouldFetchBedrock) {
bedrockResponse =
- bedrockConfigMode === 'bedrock'
+ bedrockConfigModeRef.current === 'bedrock'
? await requestAllModelsByBedrockAPI()
: await requestAllModels();
addBedrockPrefixToDeepseekModels(bedrockResponse.textModel);
@@ -250,7 +251,7 @@ function SettingsScreen(): React.JSX.Element {
});
}
},
- [bedrockConfigMode, textModels, imageModels]
+ [textModels, imageModels]
);
const fetchAndSetModelNamesRef = useRef(fetchAndSetModelNames);
@@ -331,6 +332,7 @@ function SettingsScreen(): React.JSX.Element {
}, [openAICompatConfigs]);
useEffect(() => {
+ bedrockConfigModeRef.current = bedrockConfigMode;
if (bedrockConfigMode === getBedrockConfigMode()) {
return;
}
diff --git a/react-native/src/storage/Constants.ts b/react-native/src/storage/Constants.ts
index 85cbaf77..282ede55 100644
--- a/react-native/src/storage/Constants.ts
+++ b/react-native/src/storage/Constants.ts
@@ -67,7 +67,9 @@ export const DeepSeekModels = [
export const BedrockThinkingModels = [
'Claude 3.7 Sonnet',
'Claude Sonnet 4',
+ 'Claude Sonnet 4.5',
'Claude Opus 4',
+ 'Claude Opus 4.1',
];
export const BedrockVoiceModels = ['Nova Sonic'];