yourname 2 月之前
父節點
當前提交
c2e9a5977b
共有 100 個文件被更改,包括 5317 次插入1 次删除
  1. 8 0
      .eslintignore
  2. 50 0
      .eslintrc
  3. 135 0
      .gitignore
  4. 6 0
      .prettierrc
  5. 68 1
      README.md
  6. 16 0
      index.html
  7. 89 0
      package.json
  8. 76 0
      public/locales/en/translation.json
  9. 76 0
      public/locales/zh/translation.json
  10. 1 0
      public/vite.svg
  11. 11 0
      src/App.tsx
  12. 1 0
      src/assets/ai.svg
  13. 3 0
      src/assets/arrow_up.svg
  14. 3 0
      src/assets/cam_mute.svg
  15. 3 0
      src/assets/cam_unmute.svg
  16. 1 0
      src/assets/caption.svg
  17. 二進制
      src/assets/github.jpg
  18. 3 0
      src/assets/host.svg
  19. 二進制
      src/assets/login_background.jpg
  20. 二進制
      src/assets/login_logo.png
  21. 3 0
      src/assets/member.svg
  22. 3 0
      src/assets/mic_mute.svg
  23. 3 0
      src/assets/mic_unmute.svg
  24. 7 0
      src/assets/network/average.svg
  25. 9 0
      src/assets/network/disconnected.svg
  26. 6 0
      src/assets/network/excellent.svg
  27. 7 0
      src/assets/network/good.svg
  28. 7 0
      src/assets/network/poor.svg
  29. 3 0
      src/assets/setting.svg
  30. 3 0
      src/assets/time.svg
  31. 5 0
      src/assets/transcription.svg
  32. 5 0
      src/assets/upload.svg
  33. 591 0
      src/common/constant.ts
  34. 45 0
      src/common/final.ts
  35. 138 0
      src/common/hooks.ts
  36. 7 0
      src/common/index.ts
  37. 50 0
      src/common/mock.ts
  38. 243 0
      src/common/request.ts
  39. 51 0
      src/common/storage.ts
  40. 147 0
      src/common/utils.ts
  41. 55 0
      src/components/avatar/index.module.scss
  42. 34 0
      src/components/avatar/index.tsx
  43. 36 0
      src/components/caption/caption-item/index.module.scss
  44. 29 0
      src/components/caption/caption-item/index.tsx
  45. 25 0
      src/components/caption/index.module.scss
  46. 75 0
      src/components/caption/index.tsx
  47. 84 0
      src/components/center-area/index.module.scss
  48. 137 0
      src/components/center-area/index.tsx
  49. 59 0
      src/components/dialog/language-setting/index.module.scss
  50. 268 0
      src/components/dialog/language-setting/index.tsx
  51. 57 0
      src/components/dialog/language-show/index.module.scss
  52. 180 0
      src/components/dialog/language-show/index.tsx
  53. 57 0
      src/components/dialog/language-storage/index.module.scss
  54. 144 0
      src/components/dialog/language-storage/index.tsx
  55. 41 0
      src/components/extend-message/index.module.scss
  56. 35 0
      src/components/extend-message/index.tsx
  57. 28 0
      src/components/footer/caption-popover/index.module.scss
  58. 104 0
      src/components/footer/caption-popover/index.tsx
  59. 95 0
      src/components/footer/index.module.scss
  60. 193 0
      src/components/footer/index.tsx
  61. 39 0
      src/components/header/index.module.scss
  62. 61 0
      src/components/header/index.tsx
  63. 0 0
      src/components/header/network/index.module.scss
  64. 27 0
      src/components/header/network/index.tsx
  65. 16 0
      src/components/header/time/index.module.scss
  66. 34 0
      src/components/header/time/index.tsx
  67. 16 0
      src/components/icons/ai/index.tsx
  68. 6 0
      src/components/icons/arrow-up/index.tsx
  69. 17 0
      src/components/icons/cam/index.tsx
  70. 23 0
      src/components/icons/caption/index.tsx
  71. 6 0
      src/components/icons/host/index.tsx
  72. 12 0
      src/components/icons/index.tsx
  73. 16 0
      src/components/icons/member/index.tsx
  74. 23 0
      src/components/icons/mic/index.tsx
  75. 35 0
      src/components/icons/network/index.tsx
  76. 17 0
      src/components/icons/setting/index.tsx
  77. 6 0
      src/components/icons/time/index.tsx
  78. 17 0
      src/components/icons/transcription/index.tsx
  79. 5 0
      src/components/icons/types.ts
  80. 10 0
      src/components/icons/upload/index.tsx
  81. 23 0
      src/components/menu/index.module.scss
  82. 77 0
      src/components/menu/index.tsx
  83. 163 0
      src/components/menu/menu-content/ai-assistant/index.module.scss
  84. 185 0
      src/components/menu/menu-content/ai-assistant/index.tsx
  85. 37 0
      src/components/menu/menu-content/dialogue-record/index.module.scss
  86. 77 0
      src/components/menu/menu-content/dialogue-record/index.tsx
  87. 89 0
      src/components/menu/menu-content/dialogue-record/record-content/index.module.scss
  88. 117 0
      src/components/menu/menu-content/dialogue-record/record-content/index.tsx
  89. 72 0
      src/components/menu/menu-content/dialogue-record/record-header/index.module.scss
  90. 93 0
      src/components/menu/menu-content/dialogue-record/record-header/index.tsx
  91. 5 0
      src/components/menu/menu-content/index.module.scss
  92. 34 0
      src/components/menu/menu-content/index.tsx
  93. 71 0
      src/components/menu/menu-title/index.module.scss
  94. 82 0
      src/components/menu/menu-title/index.tsx
  95. 6 0
      src/components/stream-player/index.module.scss
  96. 2 0
      src/components/stream-player/index.tsx
  97. 46 0
      src/components/stream-player/localStreamPlayer.tsx
  98. 45 0
      src/components/stream-player/remoteStreamPlayer.tsx
  99. 45 0
      src/components/user-list/index.module.scss
  100. 43 0
      src/components/user-list/index.tsx

+ 8 - 0
.eslintignore

@@ -0,0 +1,8 @@
+node_modules
+dist
+es
+build
+assets
+out
+lib
+plugin

+ 50 - 0
.eslintrc

@@ -0,0 +1,50 @@
+{
+  "env": {
+    "browser": true,
+    "es2021": true
+  },
+  "parser": "@typescript-eslint/parser",
+  "extends": [
+    "standard",
+    "plugin:@typescript-eslint/recommended",
+    "plugin:prettier/recommended"
+  ],
+  "parserOptions": {
+    "ecmaVersion": "latest",
+    "sourceType": "module"
+  },
+  "plugins": [
+    "react",
+    "@typescript-eslint"
+  ],
+  "ignorePatterns": [],
+  "rules": {
+    "new-cap": [
+      "off"
+    ],
+    "no-unused-vars": [
+      "off"
+    ],
+    "n/handle-callback-err": "off",
+    "n/no-callback-literal":"off",
+    "n/no-path-concat": "warn",
+    "eqeqeq": "off",
+    "prefer-spread": "warn",
+    "no-useless-catch": "warn",
+    "no-use-before-define": "off",
+    "camelcase": "off",
+    "no-case-declarations": "warn",
+    "no-useless-call": "warn",
+    "no-useless-constructor": "off",
+    "no-unused-expressions":"off",
+    "@typescript-eslint/no-unused-vars": "off",
+    "@typescript-eslint/no-explicit-any": "off",
+    "@typescript-eslint/ban-ts-comment": "off",
+    "@typescript-eslint/no-this-alias": "off",
+    "@typescript-eslint/no-var-requires": "off",
+    "@typescript-eslint/no-empty-function": "off",
+    "@typescript-eslint/ban-types": [
+      "warn"
+    ]
+  }
+}

+ 135 - 0
.gitignore

@@ -0,0 +1,135 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+.cache
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+
+# lock
+yarn.lock
+package-lock.json

+ 6 - 0
.prettierrc

@@ -0,0 +1,6 @@
+{
+  "printWidth": 100,
+  "semi": false,
+  "singleQuote": false,
+  "tabWidth": 2
+}

+ 68 - 1
README.md

@@ -1,2 +1,69 @@
-# blank
+# Agora RTT Web Demo
 
+
+
+## Prepare
+
+* node version 18+ , 20+
+
+
+
+## Config
+
+See [Get Started with Agora](https://docs.agora.io/en/video-calling/reference/manage-agora-account?platform=web#get-started-with-agora) to learn how to get an App ID and App Certificate. (Certificate must be turned on)
+
+
+
+Activate RTM permissions in the console
+
+<img src=https://fullapp.oss-cn-beijing.aliyuncs.com/pic/rtm/39351715138175_.pic.jpg width=80% />
+
+
+
+
+
+Contact technical support to activate RTT permissions
+
+- You can get help from intelligent customer service or contact sales staff [Agora support](https://agora-ticket.agora.io/) 
+- Send an email to  [support@agora.io](mailto:support@agora.io)  for consultation
+
+
+
+Find `.env`  file  and fill in the following parameters correctly
+
+```bash
+VITE_AGORA_APP_ID=<YOUR_APP_ID>
+VITE_AGORA_APP_CERTIFICATE=<YOUR_APP_CERTIFICATE>
+```
+
+
+
+## Install
+
+In the project root path run the following command to install dependencies.
+
+```bash
+npm install 
+```
+
+
+
+
+
+## Dev
+
+Use the following command to run the sample project.
+
+```bash
+npm run dev
+```
+
+
+
+## Build
+
+Use the following command to build the sample project.
+
+```bash
+npm run build
+```

+ 16 - 0
index.html

@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta
+      name="viewport"
+      content="viewport-fit=cover,width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"
+    />
+    <title>agora stt demo</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 89 - 0
package.json

@@ -0,0 +1,89 @@
+{
+  "name": "stt-demo",
+  "version": "1.0.0",
+  "license": "MIT",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "dev:test": "vite --mode test",
+    "build": "vite build",
+    "build:test": "vite build --mode test",
+    "lint": "eslint --cache .",
+    "lint:fix": "npm run lint --fix",
+    "prettier": "prettier . --write --ignore-unknown",
+    "preview": "vite preview",
+    "proto": "pbjs -t json-module -w es6 --es6 -o src/protobuf/SttMessage.js src/protobuf/SttMessage.proto"
+  },
+  "sideEffects": [
+    "*.css"
+  ],
+  "dependencies": {
+    "@reduxjs/toolkit": "^1.6.2",
+    "agora-rtc-sdk-ng": "4.20.0",
+    "agora-rtm": "2.1.9",
+    "axios": "^1.6.7",
+    "lodash-es": "^4.17.21",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "react-redux": "^7.2.6",
+    "react-router-dom": "^6.21.3",
+    "redux": "^4.1.2",
+    "antd": "^5.15.3",
+    "@ant-design/icons": "^4.8.2",
+    "protobufjs": "^7.2.5",
+    "i18next": "23.8.2",
+    "react-i18next": "^14.1.0",
+    "i18next-browser-languagedetector": "7.2.0",
+    "i18next-http-backend": "2.4.3"
+  },
+  "devDependencies": {
+    "@types/axios": "^0.14.0",
+    "@types/lodash-es": "^4.17.6",
+    "@types/react": "^18.2.43",
+    "@types/react-dom": "^18.2.17",
+    "@types/react-redux": "^7.1.22",
+    "@typescript-eslint/eslint-plugin": "^6.14.0",
+    "@typescript-eslint/parser": "^6.14.0",
+    "@vitejs/plugin-react": "^4.2.1",
+    "vite-plugin-svgr": "^4.2.0",
+    "eslint": "^8.55.0",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-config-standard": "^17.1.0",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-n": "^16.6.2",
+    "eslint-plugin-prettier": "^5.1.3",
+    "eslint-plugin-promise": "^6.1.1",
+    "eslint-plugin-react": "^7.30.1",
+    "lint-staged": "^13.0.3",
+    "postcss": "^8.4.21",
+    "prettier": "^3.2.5",
+    "sass": "^1.70.0",
+    "typescript": "^5.2.2",
+    "vite": "^5.0.8",
+    "yorkie": "^2.0.0",
+    "protobufjs-cli": "^1.1.2"
+  },
+  "gitHooks": {
+    "pre-commit": "lint-staged"
+  },
+  "lint-staged": {
+    "*.{js,jsx,ts,tsx}": [
+      "eslint --cache --fix",
+      "git add"
+    ],
+    "**/*": "prettier --write --ignore-unknown"
+  },
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not op_mini all"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  },
+  "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
+}

+ 76 - 0
public/locales/en/translation.json

@@ -0,0 +1,76 @@
+{
+  "confirm": "Confirm",
+  "liveLanguage": "Live Language",
+  "translationLanguage": "Translation Language",
+  "clearAll": "Clear All",
+  "analyze": "Analyze",
+  "denoise": "Denoise",
+  "analysisResult": "Analysis Result",
+  "prompt": "Prompt",
+  "conversationText": "Conversation Text",
+  "loadText": "Load Text",
+  "transcribing": "Transcribing",
+  "closeConversation": "Close Conversation",
+  "storage": {
+    "text": "Storage",
+    "success": "Storage success"
+  },
+  "export": {
+    "text": "Export",
+    "success": "Export success"
+  },
+  "dialog": {
+    "languageCaption": "Language Caption",
+    "languageShow": "Language Display",
+    "languageExport": "Language Export",
+    "languageExportTip": "Please select the language to export"
+  },
+  "login": {
+    "github": "Visit GitHub",
+    "join": "join channel",
+    "title": "Real-Time Transcription"
+  },
+  "footer": {
+    "aIAssistant": "AI Assistant",
+    "langaugesSetting": "Set & Start",
+    "conversationHistory": "Conversation History",
+    "participantsList": "Participants List",
+    "muteAudio": "Mute Audio",
+    "unMuteAudio": "UnMute Audio",
+    "muteVideo": "Mute Video",
+    "unMuteVideo": "UnMute Video",
+    "startCC": "Start CC",
+    "stopCC": "Stop CC",
+    "tipEnableSTTFirst": "Please set language and start STT or Translation service",
+    "tipHostEnableCC": "Only the host can operate"
+  },
+  "setting": {
+    "sttStarted": "Speech to text has started",
+    "sttStopped": "Speech to text has stopped",
+    "sttStart": "Start speech-to-text",
+    "sttStop": "Stop speech-to-text",
+    "limitDuration": "The default speech-to-text time limit is 10 minutes",
+    "languagesSelect": "Please select on-site language and translation language",
+    "tip": "You can select up to two languages, and each live language can correspond to up to five translation languages",
+    "liveLanguage": "Live language",
+    "translationLanguageMax": "Translation language should less than or equal to 5",
+    "saveSuccess": "Setup success",
+    "sameLanguage": "The live language cannot be the same"
+  },
+  "conversation": {
+    "sttStarted": "The host has turned on real-time transcription",
+    "sttStopped": "Language to text service is not enabled",
+    "onTrial": "On trial",
+    "extendExperience": "Extend",
+    "setLanguage": "Render Langauges selection",
+    "extendExperienceText": "Extended experience can be extended by 10 minutes",
+    "extendExperienceFreeText": "The free version of the language to text service trial lasts for 10 minutes and has been used up."
+  },
+  "ai": {
+    "selectPromptSample": "Select Prompt Sample",
+    "selectConversation": "Select Conversation Text"
+  },
+  "message": {
+    "extendExperience": "Extended experience time success"
+  }
+}

+ 76 - 0
public/locales/zh/translation.json

@@ -0,0 +1,76 @@
+{
+  "confirm": "确认",
+  "liveLanguage": "现场语言",
+  "translationLanguage": "翻译语言",
+  "clearAll": "清除全部内容",
+  "analyze": "分析",
+  "denoise": "降噪",
+  "analysisResult": "分析结果",
+  "prompt": "提示",
+  "conversationText": "对话文本",
+  "transcribing": "转写中",
+  "loadText": "加载文本",
+  "closeConversation": "结束对话",
+  "storage": {
+    "text": "存储",
+    "success": "存储成功"
+  },
+  "export": {
+    "text": "导出",
+    "success": "导出成功"
+  },
+  "dialog": {
+    "languageCaption": "字幕语言",
+    "languageShow": "语言展示",
+    "languageExport": "语言导出",
+    "languageExportTip": "请选择一种语言导出"
+  },
+  "login": {
+    "github": "访问 GitHub",
+    "join": "加入频道",
+    "title": "实时语音转文字"
+  },
+  "footer": {
+    "aIAssistant": "AI助手",
+    "langaugesSetting": "配置与启动",
+    "conversationHistory": "对话记录",
+    "participantsList": "成员列表",
+    "muteAudio": "开启静音",
+    "unMuteAudio": "解除静音",
+    "muteVideo": "关闭视频",
+    "unMuteVideo": "开启视频",
+    "startCC": "开启字幕",
+    "stopCC": "关闭字幕",
+    "tipEnableSTTFirst": "请设置语言并启动转录或翻译服务",
+    "tipHostEnableCC": "只有主持人才能操作"
+  },
+  "setting": {
+    "sttStarted": "语音转文字已开始",
+    "sttStopped": "语音转文字已停止",
+    "sttStart": "开始语音转文字",
+    "sttStop": "停止语音转文字",
+    "limitDuration": "语音转文字默认限制时长10分钟",
+    "languagesSelect": "请选择现场语言和翻译语言",
+    "tip": "您最多可以选择两种语言,每个现场语言可最多对应五种翻译语言",
+    "liveLanguage": "现场语言",
+    "translationLanguageMax": "翻译语言最多可以选择5种",
+    "saveSuccess": "设置成功",
+    "sameLanguage": "现场语言不能相同"
+  },
+  "conversation": {
+    "sttStarted": "主持人已开启实时转写",
+    "sttStopped": "语言转文字服务未开启",
+    "onTrial": "试用中",
+    "extendExperience": "延长体验",
+    "extendExperienceText": "延长体验可延长使用10分钟",
+    "setLanguage": "展示语言选择",
+    "extendExperienceFreeText": "免费版语音转文字体验时长 10 分钟,当前已用完"
+  },
+  "ai": {
+    "selectPromptSample": "选择提示模板",
+    "selectConversation": "选择对话范例"
+  },
+  "message": {
+    "extendExperience": "延长体验时间成功"
+  }
+}

+ 1 - 0
public/vite.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 11 - 0
src/App.tsx

@@ -0,0 +1,11 @@
+import { useCatchError, useScreenResize } from "@/common"
+import { RouteContainer } from "./router"
+
+function App() {
+  useCatchError()
+  useScreenResize()
+
+  return <RouteContainer></RouteContainer>
+}
+
+export default App

File diff suppressed because it is too large
+ 1 - 0
src/assets/ai.svg


+ 3 - 0
src/assets/arrow_up.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11.2929 8.29289C11.6834 7.90237 12.3166 7.90237 12.7071 8.29289L18.7071 14.2929C19.0976 14.6834 19.0976 15.3166 18.7071 15.7071C18.3166 16.0976 17.6834 16.0976 17.2929 15.7071L12 10.4142L6.70711 15.7071C6.31658 16.0976 5.68342 16.0976 5.29289 15.7071C4.90237 15.3166 4.90237 14.6834 5.29289 14.2929L11.2929 8.29289Z" fill="#667085" stroke="#667085" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
src/assets/cam_mute.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M4.96317 1.33683C4.51407 0.887724 3.78593 0.887724 3.33683 1.33683C2.88772 1.78593 2.88772 2.51407 3.33683 2.96317L4.80982 4.43617C3.76782 5.81737 3.14999 7.53655 3.14999 9.40002C3.14999 13.4792 6.11045 16.8668 10 17.5317V20.85H6.14999C5.51486 20.85 4.99999 21.3648 4.99999 22C4.99999 22.6351 5.51486 23.15 6.14999 23.15H16.15C16.7851 23.15 17.3 22.6351 17.3 22C17.3 21.3648 16.7851 20.85 16.15 20.85H12.3V17.6015C13.8156 17.4371 15.2081 16.8621 16.3639 15.9902L18.3368 17.9632C18.7859 18.4123 19.5141 18.4123 19.9632 17.9632C20.4123 17.5141 20.4123 16.7859 19.9632 16.3368L4.96317 1.33683ZM14.7152 14.3416L12.9999 12.6263C12.5601 12.8339 12.0686 12.95 11.55 12.95C9.67223 12.95 8.14999 11.4278 8.14999 9.55002C8.14999 9.03141 8.26611 8.53991 8.47376 8.10011L6.45844 6.08478C5.8216 7.03215 5.44999 8.17269 5.44999 9.40002C5.44999 12.6861 8.1139 15.35 11.4 15.35C12.6273 15.35 13.7679 14.9784 14.7152 14.3416ZM11.7843 6.15797L14.942 9.3157C14.8269 7.62466 13.4754 6.27308 11.7843 6.15797ZM17.35 9.40002C17.35 10.0959 17.2305 10.7639 17.011 11.3846L18.7592 13.1328C19.3289 12.012 19.65 10.7435 19.65 9.40002C19.65 4.84368 15.9563 1.15002 11.4 1.15002C10.0565 1.15002 8.78807 1.47115 7.66718 2.04083L9.41539 3.78904C10.0361 3.56949 10.7041 3.45002 11.4 3.45002C14.6861 3.45002 17.35 6.11393 17.35 9.40002Z" fill="#667085"/>
+</svg>

+ 3 - 0
src/assets/cam_unmute.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.3499 9.40002C17.3499 12.6861 14.686 15.35 11.3999 15.35C8.11381 15.35 5.4499 12.6861 5.4499 9.40002C5.4499 6.11393 8.11381 3.45002 11.3999 3.45002C14.686 3.45002 17.3499 6.11393 17.3499 9.40002ZM19.6499 9.40002C19.6499 13.6522 16.4329 17.1531 12.2999 17.6015V20.85H16.1499C16.785 20.85 17.2999 21.3648 17.2999 22C17.2999 22.6351 16.785 23.15 16.1499 23.15H6.1499C5.51477 23.15 4.9999 22.6351 4.9999 22C4.9999 21.3648 5.51477 20.85 6.1499 20.85H9.99991V17.5317C6.11036 16.8668 3.1499 13.4792 3.1499 9.40002C3.1499 4.84368 6.84355 1.15002 11.3999 1.15002C15.9563 1.15002 19.6499 4.84368 19.6499 9.40002ZM11.5499 12.95C13.4277 12.95 14.9499 11.4278 14.9499 9.55002C14.9499 7.67226 13.4277 6.15002 11.5499 6.15002C9.67213 6.15002 8.1499 7.67226 8.1499 9.55002C8.1499 11.4278 9.67213 12.95 11.5499 12.95Z" fill="#3D53F5"/>
+</svg>

File diff suppressed because it is too large
+ 1 - 0
src/assets/caption.svg


二進制
src/assets/github.jpg


+ 3 - 0
src/assets/host.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0001 4C12.4305 4 12.8126 4.27543 12.9487 4.68377L14.9137 10.5786C15.2265 11.5169 16.3458 11.901 17.1688 11.3523L20.4454 9.16795C20.8065 8.9272 21.2817 8.94688 21.6217 9.21666C21.9616 9.48645 22.0888 9.94474 21.9364 10.3511L19.0369 18.0831C18.8106 18.6865 18.1889 19.0453 17.5533 18.9393C13.8765 18.3265 10.1236 18.3265 6.44679 18.9393C5.81117 19.0453 5.18949 18.6865 4.96323 18.0831L2.06373 10.3511C1.91134 9.94474 2.03848 9.48645 2.37846 9.21666C2.71844 8.94688 3.19364 8.9272 3.55476 9.16795L6.83136 11.3523C7.6543 11.901 8.77366 11.5169 9.08643 10.5786L11.0514 4.68377C11.1875 4.27543 11.5696 4 12.0001 4Z" fill="currentColor"/>
+</svg>

二進制
src/assets/login_background.jpg


二進制
src/assets/login_logo.png


+ 3 - 0
src/assets/member.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.50004 2C6.46248 2 4.00004 4.46243 4.00004 7.5C4.00004 10.5376 6.46248 13 9.50004 13C12.5376 13 15 10.5376 15 7.5C15 4.46243 12.5376 2 9.50004 2ZM16.4451 2.57225C15.9505 2.32646 15.3503 2.52815 15.1045 3.02273C14.8587 3.51731 15.0604 4.11749 15.555 4.36327C16.7099 4.9372 17.5 6.12724 17.5 7.5C17.5 8.87276 16.7099 10.0628 15.555 10.6367C15.0604 10.8825 14.8587 11.4827 15.1045 11.9773C15.3503 12.4719 15.9505 12.6735 16.4451 12.4278C18.2536 11.529 19.5 9.66096 19.5 7.5C19.5 5.33904 18.2536 3.47103 16.4451 2.57225ZM18.4123 15.8553C17.9091 15.6276 17.3167 15.851 17.089 16.3541C16.8613 16.8573 17.0846 17.4498 17.5878 17.6775C18.9328 18.2861 20.171 19.2907 21.2137 20.6178C21.5549 21.0521 22.1836 21.1275 22.6179 20.7863C23.0521 20.4451 23.1276 19.8165 22.7864 19.3822C21.5741 17.8393 20.0902 16.6146 18.4123 15.8553ZM1.21372 19.3822C0.872508 19.8165 0.947955 20.4451 1.38223 20.7863H17.6179C18.0521 20.4451 18.1276 19.8165 17.7864 19.3822C15.6946 16.7198 12.7829 15 9.50004 15C6.21717 15 3.3055 16.7198 1.21372 19.3822Z" fill="currentColor"/>
+</svg>

+ 3 - 0
src/assets/mic_mute.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M6.3 7.92635L3.33683 4.96317C2.88772 4.51407 2.88772 3.78593 3.33683 3.33683C3.78593 2.88772 4.51407 2.88772 4.96317 3.33683L21.9632 20.3368C22.4123 20.7859 22.4123 21.5141 21.9632 21.9632C21.5141 22.4123 20.7859 22.4123 20.3368 21.9632L18.8367 20.463C13.6456 24.5199 5.41626 23.2537 2.12166 16.6645C1.83762 16.0965 2.06788 15.4057 2.63596 15.1217C3.20403 14.8376 3.89481 15.0679 4.17884 15.636C6.74084 20.7599 13.0699 21.8205 17.1912 18.8176L15.7744 17.4008C14.7706 18.2621 13.4656 18.7826 12.0391 18.7826C8.8695 18.7826 6.3 16.2131 6.3 13.0435V7.92635ZM14.1401 15.7664C13.559 16.2154 12.8303 16.4826 12.0391 16.4826C10.1398 16.4826 8.6 14.9428 8.6 13.0435V10.2263L14.1401 15.7664ZM17.6882 14.0618L15.4783 11.8519V6.78259C15.4783 4.88321 13.9385 3.34346 12.0391 3.34346C10.6683 3.34346 9.48479 4.1455 8.93227 5.30592L7.2481 3.62175C8.27509 2.06823 10.0374 1.04346 12.0391 1.04346C15.2088 1.04346 17.7783 3.61295 17.7783 6.78259V13.0435C17.7783 13.391 17.7474 13.7313 17.6882 14.0618ZM19.6535 16.0272L21.3305 17.7041C21.5404 17.3737 21.7366 17.0272 21.918 16.6645C22.202 16.0965 21.9718 15.4057 21.4037 15.1217C20.8356 14.8376 20.1448 15.0679 19.8608 15.636C19.7942 15.7691 19.7251 15.8995 19.6535 16.0272Z" fill="currentColor"/>
+</svg>

+ 3 - 0
src/assets/mic_unmute.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0391 1.04346C8.86948 1.04346 6.29999 3.61295 6.29999 6.78259V13.0435C6.29999 16.2131 8.86948 18.7826 12.0391 18.7826C15.2088 18.7826 17.7782 16.2131 17.7782 13.0435V6.78259C17.7782 3.61295 15.2088 1.04346 12.0391 1.04346ZM4.17884 15.6596C3.89481 15.0916 3.20403 14.8613 2.63596 15.1453C2.06788 15.4294 1.83762 16.1202 2.12166 16.6882C6.19993 24.8448 17.8397 24.8448 21.918 16.6882C22.202 16.1202 21.9718 15.4294 21.4037 15.1453C20.8356 14.8613 20.1448 15.0916 19.8608 15.6596C16.6301 22.121 7.4095 22.121 4.17884 15.6596Z" fill="currentColor"/>
+</svg>

+ 7 - 0
src/assets/network/average.svg

@@ -0,0 +1,7 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M9.49875 10.8689C10.1539 10.8689 10.685 11.3753 10.685 12V19.8689C10.685 20.4936 10.1539 21 9.49875 21C8.8436 21 8.3125 20.4936 8.3125 19.8689V12C8.3125 11.3753 8.8436 10.8689 9.49875 10.8689Z" fill="#FFAB08"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M4.18625 14.8033C4.84139 14.8033 5.37249 15.3097 5.37249 15.9344V19.8689C5.37249 20.4936 4.84139 21 4.18625 21C3.5311 21 3 20.4936 3 19.8689V15.9344C3 15.3097 3.5311 14.8033 4.18625 14.8033Z" fill="#FFAB08"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M14.656 7.91803C15.3111 7.91803 15.8422 8.42446 15.8422 9.04918V19.8688C15.8422 20.4936 15.3111 21 14.656 21C14.0008 21 13.4697 20.4936 13.4697 19.8688V9.04918C13.4697 8.42446 14.0008 7.91803 14.656 7.91803Z" fill="#D0D5DD"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M19.8137 3C20.4688 3 20.9999 3.50643 20.9999 4.13115V19.8689C20.9999 20.4936 20.4688 21 19.8137 21C19.1585 21 18.6274 20.4936 18.6274 19.8689V4.13115C18.6274 3.50643 19.1585 3 19.8137 3Z" fill="#D0D5DD"/>
+  </svg>
+  

+ 9 - 0
src/assets/network/disconnected.svg

@@ -0,0 +1,9 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path fill-rule="evenodd" clip-rule="evenodd"
+    d="M21 4.13115C21 3.50643 20.4689 3 19.8138 3C19.1586 3 18.6275 3.50643 18.6275 4.13115V19.8689C18.6275 20.4936 19.1586 21 19.8138 21C20.4689 21 21 20.4936 21 19.8689V4.13115ZM14.6562 7.91803C15.3113 7.91803 15.8424 8.42447 15.8424 9.04918V19.8689C15.8424 20.4936 15.3113 21 14.6562 21C14.001 21 13.4699 20.4936 13.4699 19.8689V9.04918C13.4699 8.42447 14.001 7.91803 14.6562 7.91803ZM10.6848 12C10.6848 11.3753 10.1537 10.8689 9.49856 10.8689C8.84342 10.8689 8.31232 11.3753 8.31232 12V19.8689C8.31232 20.4936 8.84342 21 9.49856 21C10.1537 21 10.6848 20.4936 10.6848 19.8689V12ZM5.37249 15.9344C5.37249 15.3097 4.84139 14.8033 4.18625 14.8033C3.5311 14.8033 3 15.3097 3 15.9344V19.8689C3 20.4936 3.5311 21 4.18625 21C4.84139 21 5.37249 20.4936 5.37249 19.8689V15.9344Z"
+    fill="#D0D5DD" />
+  <path fill-rule="evenodd" clip-rule="evenodd"
+    d="M13.2441 12.2441C13.5695 11.9186 14.0972 11.9186 14.4226 12.2441L18 15.8215L21.5774 12.2441C21.9028 11.9186 22.4305 11.9186 22.7559 12.2441C23.0814 12.5695 23.0814 13.0972 22.7559 13.4226L19.1785 17L22.7559 20.5774C23.0814 20.9028 23.0814 21.4305 22.7559 21.7559C22.4305 22.0814 21.9028 22.0814 21.5774 21.7559L18 18.1785L14.4226 21.7559C14.0972 22.0814 13.5695 22.0814 13.2441 21.7559C12.9186 21.4305 12.9186 20.9028 13.2441 20.5774L16.8215 17L13.2441 13.4226C12.9186 13.0972 12.9186 12.5695 13.2441 12.2441Z"
+    fill="#DF1642" />
+</svg>
+  

+ 6 - 0
src/assets/network/excellent.svg

@@ -0,0 +1,6 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path fill-rule="evenodd" clip-rule="evenodd"
+    d="M21 4.13115C21 3.50643 20.4689 3 19.8138 3C19.1586 3 18.6275 3.50643 18.6275 4.13115V19.8689C18.6275 20.4936 19.1586 21 19.8138 21C20.4689 21 21 20.4936 21 19.8689V4.13115ZM14.6562 7.91803C15.3113 7.91803 15.8424 8.42447 15.8424 9.04918V19.8689C15.8424 20.4936 15.3113 21 14.6562 21C14.001 21 13.4699 20.4936 13.4699 19.8689V9.04918C13.4699 8.42447 14.001 7.91803 14.6562 7.91803ZM10.6848 12C10.6848 11.3753 10.1537 10.8689 9.49856 10.8689C8.84342 10.8689 8.31232 11.3753 8.31232 12V19.8689C8.31232 20.4936 8.84342 21 9.49856 21C10.1537 21 10.6848 20.4936 10.6848 19.8689V12ZM5.37249 15.9344C5.37249 15.3097 4.84139 14.8033 4.18625 14.8033C3.5311 14.8033 3 15.3097 3 15.9344V19.8689C3 20.4936 3.5311 21 4.18625 21C4.84139 21 5.37249 20.4936 5.37249 19.8689V15.9344Z"
+    fill="#18A957" />
+</svg>
+  

+ 7 - 0
src/assets/network/good.svg

@@ -0,0 +1,7 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M9.49875 10.8689C10.1539 10.8689 10.685 11.3753 10.685 12V19.8689C10.685 20.4936 10.1539 21 9.49875 21C8.8436 21 8.3125 20.4936 8.3125 19.8689V12C8.3125 11.3753 8.8436 10.8689 9.49875 10.8689Z" fill="#18A957"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M4.18625 14.8033C4.84139 14.8033 5.37249 15.3097 5.37249 15.9344V19.8689C5.37249 20.4936 4.84139 21 4.18625 21C3.5311 21 3 20.4936 3 19.8689V15.9344C3 15.3097 3.5311 14.8033 4.18625 14.8033Z" fill="#18A957"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M14.656 7.91803C15.3111 7.91803 15.8422 8.42446 15.8422 9.04918V19.8688C15.8422 20.4936 15.3111 21 14.656 21C14.0008 21 13.4697 20.4936 13.4697 19.8688V9.04918C13.4697 8.42446 14.0008 7.91803 14.656 7.91803Z" fill="#18A957"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M19.8137 3C20.4688 3 20.9999 3.50643 20.9999 4.13115V19.8689C20.9999 20.4936 20.4688 21 19.8137 21C19.1585 21 18.6274 20.4936 18.6274 19.8689V4.13115C18.6274 3.50643 19.1585 3 19.8137 3Z" fill="#D0D5DD"/>
+  </svg>
+  

+ 7 - 0
src/assets/network/poor.svg

@@ -0,0 +1,7 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M9.49875 10.8689C10.1539 10.8689 10.685 11.3753 10.685 12V19.8689C10.685 20.4936 10.1539 21 9.49875 21C8.8436 21 8.3125 20.4936 8.3125 19.8689V12C8.3125 11.3753 8.8436 10.8689 9.49875 10.8689Z" fill="#D0D5DD"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M4.18625 14.8033C4.84139 14.8033 5.37249 15.3097 5.37249 15.9344V19.8689C5.37249 20.4936 4.84139 21 4.18625 21C3.5311 21 3 20.4936 3 19.8689V15.9344C3 15.3097 3.5311 14.8033 4.18625 14.8033Z" fill="#DF1642"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M14.656 7.91803C15.3111 7.91803 15.8422 8.42446 15.8422 9.04918V19.8688C15.8422 20.4936 15.3111 21 14.656 21C14.0008 21 13.4697 20.4936 13.4697 19.8688V9.04918C13.4697 8.42446 14.0008 7.91803 14.656 7.91803Z" fill="#D0D5DD"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M19.8137 3C20.4688 3 20.9999 3.50643 20.9999 4.13115V19.8689C20.9999 20.4936 20.4688 21 19.8137 21C19.1585 21 18.6274 20.4936 18.6274 19.8689V4.13115C18.6274 3.50643 19.1585 3 19.8137 3Z" fill="#D0D5DD"/>
+  </svg>
+  

+ 3 - 0
src/assets/setting.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.13636 3.40909C9.13636 2.35473 9.99109 1.5 11.0455 1.5H12.9545C14.0089 1.5 14.8636 2.35473 14.8636 3.40909V5.08654L16.0497 3.90049C16.7952 3.15494 18.004 3.15494 18.7495 3.90049L20.0995 5.25042C20.845 5.99597 20.845 7.20474 20.0995 7.95028L18.9134 9.13636H20.5909C21.6453 9.13636 22.5 9.99109 22.5 11.0455V12.9545C22.5 14.0089 21.6453 14.8636 20.5909 14.8636H18.9134L20.0997 16.0499C20.8452 16.7955 20.8452 18.0042 20.0997 18.7498L18.7497 20.0997C18.0042 20.8452 16.7954 20.8452 16.0499 20.0997L14.8636 18.9135V20.5909C14.8636 21.6453 14.0089 22.5 12.9545 22.5H11.0455C9.99109 22.5 9.13636 21.6453 9.13636 20.5909V18.9134L7.9501 20.0997C7.20455 20.8452 5.99578 20.8452 5.25023 20.0997L3.9003 18.7497C3.15476 18.0042 3.15476 16.7954 3.9003 16.0499L5.08654 14.8636H3.40909C2.35473 14.8636 1.5 14.0089 1.5 12.9545V11.0455C1.5 9.99109 2.35473 9.13636 3.40909 9.13636H5.08654L3.90049 7.95032C3.15494 7.20477 3.15494 5.996 3.90049 5.25045L5.25042 3.90052C5.99597 3.15498 7.20474 3.15498 7.95028 3.90052L9.13636 5.0866V3.40909ZM14 12C14 13.1046 13.1046 14 12 14C10.8954 14 10 13.1046 10 12C10 10.8954 10.8954 10 12 10C13.1046 10 14 10.8954 14 12ZM16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" fill="currentColor"/>
+</svg>

+ 3 - 0
src/assets/time.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM12 6.54545C12.5021 6.54545 12.9091 6.95247 12.9091 7.45455V12.3472L16.0429 13.9142C16.492 14.1387 16.674 14.6848 16.4495 15.1338C16.2249 15.5829 15.6789 15.7649 15.2298 15.5404L11.5934 13.7222C11.2855 13.5682 11.0909 13.2534 11.0909 12.9091V7.45455C11.0909 6.95247 11.4979 6.54545 12 6.54545Z" fill="#667085"/>
+</svg>

+ 5 - 0
src/assets/transcription.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M2.17367 11.8813C3.42871 13.7615 5.56972 15 8 15C8 11.134 11.134 8 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 8.00325 1 8.00651 1.00001 8.00976C1.00196 9.44213 1.43395 10.7732 2.17367 11.8813ZM6.5 5C6.5 4.44772 6.94772 4 7.5 4C8.05228 4 8.5 4.44772 8.5 5V10C8.5 10.5523 8.05228 11 7.5 11C6.94772 11 6.5 10.5523 6.5 10V5ZM4.5 6C5.05228 6 5.5 6.44772 5.5 7V8C5.5 8.55228 5.05228 9 4.5 9C3.94772 9 3.5 8.55228 3.5 8V7C3.5 6.44772 3.94772 6 4.5 6ZM11.5 7C11.5 6.44772 11.0523 6 10.5 6C9.94772 6 9.5 6.44772 9.5 7V8C9.5 8.55228 9.94772 9 10.5 9C11.0523 9 11.5 8.55228 11.5 8V7Z" fill="currentColor"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16 9C19.866 9 23 12.134 23 16C23 19.866 19.866 23 16 23C12.134 23 9 19.866 9 16C9 12.134 12.134 9 16 9ZM13.8273 21H18.16C18.3557 21 18.655 20.8025 18.655 20.4794C18.655 20.1079 18.4226 19.9656 18.3064 19.9606C18.265 19.9586 18.1993 19.9562 18.1194 19.9532C17.7879 19.9408 17.2123 19.9193 17.1174 19.8869C16.963 19.835 16.8579 19.7212 16.8054 19.5471C16.7529 19.3731 16.7258 19.1069 16.7258 18.7487V13.4964H17.3036C17.727 13.4964 18.3175 13.5432 18.5213 13.6386C18.725 13.7341 18.8747 13.9031 18.9686 14.1458C19.0497 14.3534 19.1261 14.6295 19.1994 14.9743C19.2216 15.0781 19.3108 15.1518 19.4127 15.1518H19.7819C19.9029 15.1518 20 15.048 20 14.9224V12.2293C20 12.1021 19.9013 12 19.7819 12H12.2181C12.0971 12 12 12.1038 12 12.2293V14.9224C12 15.0497 12.0987 15.1518 12.2181 15.1518H12.5873C12.6892 15.1518 12.7784 15.0781 12.8006 14.9743C12.8723 14.6295 12.9471 14.3534 13.0251 14.1458C13.1158 13.9031 13.2638 13.7341 13.4723 13.6386C13.6793 13.5432 14.2682 13.4964 14.6836 13.4964H15.2726V18.7487C15.2726 19.1053 15.2487 19.3731 15.1994 19.5471C15.15 19.7229 15.0466 19.835 14.8874 19.8869C14.7796 19.9223 14.099 19.9459 13.7861 19.9568L13.6809 19.9606C13.5647 19.9656 13.3323 20.1045 13.3323 20.4794C13.3323 20.851 13.6315 21 13.8273 21Z" fill="currentColor"/>
+<path d="M1 16C1 15.4477 1.44772 15 2 15C2.55228 15 3 15.4477 3 16C3 17.5048 4.10798 18.7509 5.55266 18.9669L5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929C5.68342 16.9024 6.31658 16.9024 6.70711 17.2929L8.70711 19.2929C9.09763 19.6834 9.09763 20.3166 8.70711 20.7071L6.70711 22.7071C6.31658 23.0976 5.68342 23.0976 5.29289 22.7071C4.90237 22.3166 4.90237 21.6834 5.29289 21.2929L5.60144 20.9843C3.02635 20.7813 1 18.6273 1 16Z" fill="currentColor"/>
+</svg>

+ 5 - 0
src/assets/upload.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Icon (Stroke)" fill-rule="evenodd" clip-rule="evenodd" d="M7.52858 1.52876C7.78892 1.26841 8.21103 1.26841 8.47138 1.52876L11.8047 4.86209C12.0651 5.12244 12.0651 5.54455 11.8047 5.8049C11.5444 6.06525 11.1223 6.06525 10.8619 5.8049L8.66665 3.60964V10.0002C8.66665 10.3684 8.36817 10.6668 7.99998 10.6668C7.63179 10.6668 7.33331 10.3684 7.33331 10.0002V3.60964L5.13805 5.8049C4.8777 6.06525 4.45559 6.06525 4.19524 5.8049C3.93489 5.54455 3.93489 5.12244 4.19524 4.86209L7.52858 1.52876ZM1.99998 9.3335C2.36817 9.3335 2.66665 9.63197 2.66665 10.0002V10.8002C2.66665 11.3712 2.66717 11.7594 2.69168 12.0595C2.71556 12.3517 2.75884 12.5012 2.81197 12.6055C2.9398 12.8564 3.14378 13.0603 3.39466 13.1882C3.49893 13.2413 3.6484 13.2846 3.94067 13.3085C4.24073 13.333 4.62893 13.3335 5.19998 13.3335H10.8C11.371 13.3335 11.7592 13.333 12.0593 13.3085C12.3516 13.2846 12.501 13.2413 12.6053 13.1882C12.8562 13.0603 13.0602 12.8564 13.188 12.6055C13.2411 12.5012 13.2844 12.3517 13.3083 12.0595C13.3328 11.7594 13.3333 11.3712 13.3333 10.8002V10.0002C13.3333 9.63197 13.6318 9.3335 14 9.3335C14.3682 9.3335 14.6666 9.63197 14.6666 10.0002V10.8277C14.6667 11.3644 14.6667 11.8073 14.6372 12.168C14.6066 12.5428 14.5409 12.8872 14.376 13.2108C14.1203 13.7126 13.7124 14.1205 13.2106 14.3762C12.8871 14.541 12.5426 14.6068 12.1679 14.6374C11.8071 14.6668 11.3642 14.6668 10.8275 14.6668H5.17243C4.63579 14.6668 4.19289 14.6668 3.8321 14.6374C3.45736 14.6068 3.1129 14.541 2.78934 14.3762C2.28757 14.1205 1.87963 13.7126 1.62396 13.2108C1.4591 12.8872 1.39339 12.5428 1.36278 12.168C1.3333 11.8073 1.3333 11.3643 1.33331 10.8277L1.33331 10.0002C1.33331 9.63197 1.63179 9.3335 1.99998 9.3335Z" fill="#667085"/>
+</g>
+</svg>

+ 591 - 0
src/common/constant.ts

@@ -0,0 +1,591 @@
+const MODE = import.meta.env.MODE
+
+interface ISttLanguage {
+  label: string
+  code: string
+}
+
+export let LANGUAGE_LIST: ISttLanguage[] = []
+
+if (MODE == "test") {
+  LANGUAGE_LIST = [
+    { label: "Kannada", code: "kn-IN" },
+    { label: "Gujarati", code: "gu-IN" },
+    { label: "Telugu", code: "te-IN" },
+    { label: "Tamil", code: "ta-IN" },
+    { label: "Bengali(IN)", code: "bn-IN" },
+    { label: "Hebrew", code: "he-IL	" },
+    { label: "Dutch", code: "nl-NL" },
+    { label: "Filipino", code: "fil-PH" },
+    { label: "Thai", code: "th-TH" },
+    { label: "Vietnamese", code: "vi-VN" },
+    { label: "Turkish", code: "tr-TR" },
+    { label: "Russian", code: "ru-RU" },
+    { label: "Malay", code: "ms-MY" },
+    { label: "Persian", code: "fa-IR" },
+    { label: "Chinese(HK)", code: "zh-HK" },
+    { label: "Indonesian", code: "id-ID" },
+    { label: "Arabic(JO)", code: "ar-JO" },
+    { label: "Arabic(EG)", code: "ar-EG" },
+    { label: "Arabic(SA)", code: "ar-SA" },
+    { label: "Arabic(UAE)", code: "ar-AE" },
+    { label: "Chinese(TW)", code: "zh-TW" },
+    { label: "English(US) 	", code: "en-US" },
+    { label: "Hindi", code: "hi-IN" },
+    { label: "Korean", code: "ko-KR" },
+    { label: "Japanese", code: "ja-JP" },
+    { label: "German", code: "de-DE" },
+    { label: "Spanish", code: "es-ES" },
+    { label: "French", code: "fr-FR" },
+    { label: "Italian", code: "it-IT" },
+    { label: "Chinese", code: "zh-CN" },
+    { label: "Portuguese", code: "pt-PT" },
+  ].sort((a, b) => {
+    return a.label.localeCompare(b.label)
+  })
+} else {
+  LANGUAGE_LIST = [
+    { label: "Kannada", code: "kn-IN" },
+    { label: "Gujarati", code: "gu-IN" },
+    { label: "Telugu", code: "te-IN" },
+    { label: "Tamil", code: "ta-IN" },
+    { label: "Bengali(IN)", code: "bn-IN" },
+    { label: "Hebrew", code: "he-IL	" },
+    { label: "Dutch", code: "nl-NL" },
+    { label: "Filipino", code: "fil-PH" },
+    { label: "Thai", code: "th-TH" },
+    { label: "Vietnamese", code: "vi-VN" },
+    { label: "Turkish", code: "tr-TR" },
+    { label: "Russian", code: "ru-RU" },
+    { label: "Malay", code: "ms-MY" },
+    { label: "Persian", code: "fa-IR" },
+    { label: "Chinese(HK)", code: "zh-HK" },
+    { label: "Indonesian", code: "id-ID" },
+    { label: "Arabic(JO)", code: "ar-JO" },
+    { label: "Arabic(EG)", code: "ar-EG" },
+    { label: "Arabic(SA)", code: "ar-SA" },
+    { label: "Arabic(UAE)", code: "ar-AE" },
+    { label: "Chinese(TW)", code: "zh-TW" },
+    { label: "English(US) 	", code: "en-US" },
+    { label: "Hindi", code: "hi-IN" },
+    { label: "Korean", code: "ko-KR" },
+    { label: "Japanese", code: "ja-JP" },
+    { label: "German", code: "de-DE" },
+    { label: "Spanish", code: "es-ES" },
+    { label: "French", code: "fr-FR" },
+    { label: "Italian", code: "it-IT" },
+    { label: "Chinese", code: "zh-CN" },
+    { label: "Portuguese", code: "pt-PT" },
+  ].sort((a, b) => {
+    return a.label.localeCompare(b.label)
+  })
+}
+
+export const LANGUAGE_OPTIONS = LANGUAGE_LIST.map((item) => {
+  return {
+    value: item.code,
+    label: item.label,
+  }
+})
+export const TOAST_DURATION = 5
+export const EXPERIENCE_DURATION = 10 * 60 * 1000 // ms
+
+export const AI_PROMPT_OPTIONS = [
+  {
+    label: "Meeting",
+    value: `You are an AI assistant that helps people find information.Please read below meeting transcription and summarize agenda, conclusion and action items. Please aim to retain the most important points and avoid unnecessary details or tangential points. Action items must can associate with agenda and please provdie assignee for each action item. If there is no conculsion, please response "no conclusion". :`,
+  },
+  {
+    label: "Language Learning",
+    value: `You are an assistant of label learning teacher. You need analyze a conversation of a lesson and provide students label skill level, like TOEFL score and IELTS score. In this conversation, Mantho is teacher, Washington and Saverio are student. Please summary what students interesting on. And provide suggestion for teacher for the content of next lesson.
+The output result has 3 part, the first is course summary, the second is the course evaluation for every student, the 3rd is the suggestion for the teacher :
+--------------------------------------------
+Course summary:   <list the course summary>
+--------------------------------------------
+Student Name:<student name>
+ESL Level:   <level number>
+Conversation:<Good, Satisfactory, or Need Improvement>
+Listening:   <Good, Satisfactory, or Need Improvement>
+Pronuceation:<Good, Satisfactory, or Need Improvement>
+Grammar: <Good, Satisfactory, or Need Improvement>
+Reading: <Good, Satisfactory, or Need Improvement>
+Student suggestion:  <suggetsion list>
+--------------------------------------------
+Teacher:
+<suggetsion list to improve the teaching>`,
+  },
+]
+
+export const AI_USER_CONTENT_OPTIONS = [
+  {
+    type: "Meeting",
+    label: "Meeting: 90% Accuracy",
+    value: `Jason: So what do you think you add to this Next? What are the features you add next to weight room 
+Jason: if you were on board or you were an investor? I don't know if you are an investor.
+Jason: What are you telling Benny? Hey. Dummy. You missed this, this, and this? 
+Sunny: Well, look, I I I think the the opportunity here is when Zoom took off, you know, you know, during the pandemic and at the start, it was really low friction.
+Sunny: What's happened is as it's, you know, kinda gone enterprise, it it has lost some of that low friction stuff. 
+Sunny: And now there was reasons for it.
+Sunny: People were showing up in random zooms and all that. 
+Sunny: So I'd say Go back to low friction, Minnie. Keep the cost super low. 
+Sunny: I really like this idea of shortening the meetings, and so take take you're you're already kinda doing that.
+Sunny: And so and then integrating with the other tools. 
+Sunny: I think that's the the the biggest biggest, you know, feature here is integrating into other you know, workflows that we have.
+Jason: It can connect the API to if this then matters Zapier and you've got 
+Vinny: Yeah. We did Zapier very soon as well. 
+Jason: Tons of tons of interaction.
+Vinny: By the way, it's free. So, like, we're not even charging for it right now. 
+Vinny: It's free because, you know, we want the models to learn and train 
+Vinny: so the more conversations we have, the better we're getting at at at doing this. So it's, you know 
+Jason: The good news for building services like this, I remember there was a Sequoia company, that made video conferencing as a service.
+Jason: And I think all like AWS and Google, they all provide some sort of Video relay as a service now. 
+Vinny: Yeah. Yep. Yep. Yep.
+Jason: Which one do you which ones have you tried or which one do you use? 
+Jason: Any thoughts on the back end here and how effective that is for you. 
+Vinny: I haven't been using those. Yeah. 
+Jason: So did you you didn't have to write the video back end, though. Right? 
+Vinny: No. No. No. No. we we use Agora. So I think we we use Agora on back end. 
+Vinny: but we, you know, we, like, There there are a number of companies doing it, 
+Vinny: and the Gore has been doing it for quite a while. And they're a public company and we've been working with, 
+Vinny: you know, we we kind of used them, I guess, when Clubhouse is blowing up, and they were using Goran. We said, 
+Vinny: okay. Well, we should try as well because they got some crazy amount of scale.
+Vinny: And by the way, clubhouse went the same VC route as well. 
+Vinny: They went to the VC's first. both the community and then it just blew up. And we we we started using Agora, 
+Vinny: and the product has improved dramatically over the past 2 years.
+Vinny: I mean, We, you know Of course. Product. 
+Jason: Yeah. Of course. 
+Vinny: Yeah. Yeah. 
+Vinny: And the the the founder came from Webex, and the other founder, co founders, 
+Vinny: like with, I think is a co founder of Zoom as well.
+Vinny: So, you know, these guys understand their business and we're very, like, we're we're very happy to you know, use their platform.
+Vinny: And it's going well. I mean, like, through the quality we so when we're running this stream at 6:40 by 360, we can jack it up to whatever. Right? 
+Vinny: So Yep. It's pretty, it's pretty easy for us to go up. And because while it's free right now, this is live in production, by the way. 
+Vinny: So it's just free.
+Vinny: So we're running low risk because why should we pay you know, the high risk, you know, 
+Vinny: we're not charging people. 
+Vinny: We're still in product development phase. 
+Vinny: But as the product improves and we get I think we're gonna we can go up to 4 k.
+Vinny: So, you know, and so we that could be a an upgrade package. Right? 
+Vinny: So you pay for Wakeroom. The AI is built in. You want 4 k. You pay a little bit more. 
+Vinny: You want, integrations into certain, you know, into Salesforce as a a fee for that.
+Vinny: So there's You could 
+Jason: also have a, a third tab here, which is transcript. 
+Jason: And you can annotate the transcript. So you could have another one, just the, you know, 
+Jason: running transcript And you could annotate it.Highlights. At the end of the meeting, 
+Vinny: at the end of the meeting, you're gonna get a a a meeting minutes of the entire meeting. 
+Vinny: So it it get emailed to you and then you can forward it around or you can send the people in the company.
+Vinny: So we do it when you're ready. 
+Jason: Even here, when you have an insight or a catch up or an action item, tells you the time stamp of it. And so that is super helpful. 
+Jason: If you wanted to jump to the time stamp, you could. Yeah. And 
+Vinny: it's a range. Ends it's a range. Like, the last one you came through 10:36 to 10:38.
+Vinny: So for the past 2 minutes, we'll be speaking about Agora's experience at Clubhouse and found this background. 
+Vinny: Like, so it tells you the time, like, the it's not a single time stamp. It's the range of when the conversations happen.
+Vinny: Yeah. It's great.`,
+  },
+  {
+    type: "Meeting",
+    label: "Meeting: Agora 95% Accuracy",
+    value: `Jason: So what do you think you add to this next, What are the features you add next, The weight room.   
+Jason: If you were on the board or you were an investor, I don't know if you are an investor, 
+Jason: what are you telling Vinny Hey, dummy, you missed this, this and this .   
+Sunny: Well, look, I think the opportunity here is when Zoom took off, you know, you know, during the pandemic and at the start , it was really low friction.   
+Sunny: What's happened is as it's, you know, kind of gone enterprise, it has lost some of that low friction stuff.   
+Sunny: And now there is reasons for it.   
+Sunny: People were showing up in random zooms and all that.   
+Sunny: So I'd say go back to low friction, Vinny, keep the cost super low.   
+Sunny: I really like this idea of shortening the meetings , and so you're already kind of doing that.   
+Sunny: And so, and then integrating with other tools.   
+Sunny: I think that's the, the biggest, biggest, you know, feature here is integrating into other, you know, workflow API,   
+Jason: connect the API to if this then that and Zapier and you've got   
+Vinny: yeah, we do use Zapier very soon as well.   
+Jason: Tons of tons of integration   
+Vinny: by the way, it's free. So like we're not even charging for it right now.   
+Vinny: It's free because we want the models to learn and train.   
+Vinny: And so the more conversations we have, the better we're getting at doing this. So and you know,   
+Jason: the good news for building services like this, I remember there was a Sequoia company, um, that made video conferencing as a service .   
+Jason: And I think all like AWS and Google, they all provide some sort of video relay as a service now.   
+Vinny: Yeah. Yep, yep, yep.   
+Jason: Which one do you, which ones have you tried or which one do you use.   
+Jason: Any thoughts on the back end here and how effective that is.   
+Vinny: I haven't, I haven't used any of those. Yeah.   
+Jason: So did you . You didn't have to write the video back end though, right.   
+Vinny: No, no, no, no. We use Agora, so yeah, we use Agora on the back end.   
+Vinny: Um, but we, you know , we like, there are a number of companies doing it 
+Vinny: and Agora has been doing it for quite a while and they're public company and we've been working, 
+Vinny: you know, we kind of use them I guess, when clubhouse is blowing up and they were using Agora and we said, 
+Vinny: okay, well we should try it as well because they got some crazy amount of scale.   
+Vinny: And by the way, clubhouse went the same route as well .   
+Vinny: They went to the VCs first, both the community, and then it just blew up and we started using Agora.   
+Vinny: And the product has improved dramatically over the past two years.   
+Vinny: I mean, you know , Chorus product.   
+Jason: Yeah. Adding features.   
+Vinny: Yeah, yeah.   
+Vinny: And the founder came from WebEx and the other founder co-founders.   
+Vinny: Like I think it was a co-founder of Zoom as well.   
+Vinny: So, um, you know, these guys understand their business and we're very, we're very happy to, you know, use their platform 
+Vinny: and it's going well. I mean the quality we so we're running this stream at 640 by 360 we can jack it up to whatever. Right.   
+Vinny: So, um, it's pretty, it's pretty easy for us to go up because while it's free right now, this is live in production, by the way.   
+Vinny: So it's just free.   
+Vinny: So we're running low res because why should we pay, you know, the high res.   
+Vinny: We're not charging people.   
+Vinny: We're still in product development phase.   
+Vinny: But as the product improves and I think we're going to we can go up to 4K.   
+Vinny: So, you know, so that could be an upgrade package, right.   
+Vinny: So you pay for Weightroom The AI is built in, you want 4K, you pay a little bit more , 
+Vinny: you want integrations into certain, you know, into Salesforce, There's a fee for that.   
+Vinny: So there's ways you could   
+Jason: also have a, um, a third tab here, which is transcript.   
+Jason: And you can annotate the transcript so you can have another one, just the, you know, 
+Jason: running transcript and you can annotate it highlights at the end of the meeting .   
+Vinny: At the end of the meeting, you're going to get a meeting minutes of the entire meeting.   
+Vinny: So you get emailed to you and then you can forward it around or you can send it to people in the company.   
+Vinny: So we do that already.   
+Jason: Even here , when you have an insight or a catch up or an action item, it tells you the timestamp of it. And so that is super helpful.   
+Jason: If you want it to jump to the timestamp, you could and   
+Vinny: that's a range and it's at range like the last one you just came through at 1036 to 1038.   
+Vinny: So for the past two minutes we've been speaking about Agora's experience at clubhouse and founders background 
+Vinny: like so you know, it tells you the time like it's not a single timestamp, it's the range of when the conversation is happening.   
+Jason:Yeah, that's great.`,
+  },
+  {
+    type: "Language Learning",
+    label: "Language Learning: 90% Accuracy",
+    value: `Mantho: Hello? 
+Washington: Hello, Egyptian.
+Mantho: Just give me a second to admit this person.
+Mantho: Wait. My camera is not on. Hello? 
+Washington: Hello? 
+Mantho: How are you? 
+Washington: I'm pretty well.
+Mantho: Oh, you type. Hey, Sarvario. We can hear you. You want me to hear me.
+Saverio: Hi, everyone. You can hear me.
+Washington: Yes. I agree to you.
+Mantho: So it's Washington? 
+Washington: Yes. My name is Larson.
+Mantho: Okay. Mikaela Manto, whichever 1 you're comfortable with.I'm not sure how to say your name. Is it Saverio? Yeah, 
+Saverio: it's pretty correct. Okay.
+Mantho: Thank you. Okay? Where are you from? 
+Saverio: Italy, the north of Italy.
+Mantho: Is it an Italian name? 
+Saverio: Yeah. Yeah. It's an Italian name. Yeah.
+Mantho: And question, what about you guys? 
+Saverio: I can't hear you. Your volume is too low too low.
+Washington: It's it's it's it's seems like your microphone get lost this sound, and And, yeah.
+Once they can.
+Washington: Problem with your microphone, I don't know. 2 2.
+Mantho: Okay, Just give me 1 second.
+Saverio: Okay.
+Mantho: What about now? Is it better? 
+Saverio: Yeah.
+Washington: It's better. It's better.
+Mantho: Okay. Alright. Thank you. I'm sorry about that. 
+Mantho: What was I even saying? So, yes, I'm Mikaela from South Africa, and I live in the capital city of South Africa. Called Pretoria.
+Mantho: So I don't know if you know this, but South Africa has 3 capital cities, so I live in 1 of them. And it's nice to meet you guys.
+Mantho: How What do you, what do you guys do, and why the group lessons? 
+Washington: I'm sorry. Can can you repeat?
+
+Mantho: You couldn't hear me.
+Washington: It's too loud or to to to 
+Mantho: I'm gonna try with the earphones.
+Saverio:Okay.
+Washington: Yes. It's okay now.
+Mantho: It's okay. You're saying it's okay? 
+Saverio: Yeah. Yeah.
+Washington: Yes.
+Mantho: Okay. Alright. Alright. Hopefully, it will stay like this.
+Mantho: 1 was I saying, oh, I was just asking you why what do you do and why are you why did you choose to do the group lessons? 
+Mantho: Also, by the way, before you answer me, let me know if I'm speaking too fast or too slow.
+Okay.
+Washington: Can I first 
+Mantho: Yes? 
+Washington: that's My name is Oshna. I'm from Brazil.  I I work as a software developer here, and I study English to improve. And and have to have better opportunities.
+Mantho: Alright. Alright. And what about Gisavirio? 
+Saverio: Yeah, I'm still English for a long time, I'm trying to improve my speaking skills. I work as ERP consultant, the software.
+Mantho: As a as a what? 
+Saverio: ERP, enterprises who's planning is software. For company.
+Mantho: Okay. Alright.
+Saverio: And I'm with why maybe the certification at the end of the year, so I'm taking some class on briefly online lesson, 1 to 1 and I want to add up something more like classes.
+Saverio: And your speed is okay for me? 
+Mantho: Okay. All right. That's good. That's good to hear.
+Mantho: And you said for a long time, how long is for a long time? How long have you been? 
+Saverio: Okay. Yeah. I studied at university also. I studied in 1 to 1. School also. So many years maybe.
+Mhmm.
+Mantho: Okay, all right. And what about you, Washington? How long have you been studying English? 
+Washington: I have been studying English for 2 years roughly.
+Mhmm.
+Mantho: Okay. And how has that been? How is the genio studying English been for you?
+Washington: Sorry, Gigi.
+Mantho: How has the journey of studying English been? Has it been fine? Has it been difficult? 
+Washington: In the back in the back end is is is so difficult, but but now I I am making little corrections Mhmm.
+Hello?
+Washington: My my words like to pronounce the they correct things of the real world thing.
+Washington: So it's for forget or get a preposition and this little troubles.
+Mantho: Okay. All right. And this 2 years, has it been on online lesson or has it been somewhere else?
+
+Washington: Has it has on open English, a website, open English, After that, only in online lesson and YouTube.
+Mhmm.
+Mantho: Not YouTube as well.
+Washington: You too. Yes.
+Mantho: So how on YouTube? Are you watching English videos or videos that are in English?
+Washington: I watch English videos because 
+I We use 
+Mantho: that I'm in English.
+Washington: Yes. Because it is I am more easy to understand. And I used to was to be was not I used to watch movies and series English with a subtitle in English too.
+Mantho: And why did you stop? 
+Washington: Oh, I'm sorry? 
+Mantho: Why did you stop? Because it sounds like you stopped. Are you still watching the English series? 
+Washington: I still watch series and movies.
+Mantho: Okay. Okay. Alright. Alright. 
+Mantho: Alright. And what about your scenario? What do you do outside of the English lessons to to improve your English? 
+Saverio: I'm doing a lot of things. I studied grammar by my side. I'm reviewing the grammar and go through some, yeah, some case, 
+Saverio: some exercise, you have a lot of work. And also as as Washington, I watch movie every day. hmm. Mhmm. And also listening in podcast 
+Saverio: and was so watching news like BDC, NBS, so a lot of things. Yeah.
+Mantho: Okay. Alright. That is that is definitely a good way of learning the label and improving the label.
+Mantho: I want to try out something. So since you guys are both on the journey of learning English, How about you exchange ideas?
+Mantho: How about you talk about tell each other what has worked for you, what has been difficult for you in your English journey, 
+Mantho: and what what tips and ideas do you have for any image.
+
+Saverio: What's the first? 
+Washington: Can I repeat, please? 
+Mantho: Okay. So I'm sayin, am I am I speaking to Pans? 
+Washington: For me, little 
+-- Mhmm.
+Washington: only for me.
+Mantho: Okay. Alright. So I'm saying that since the both of you, you and Sabarito are on the journey of learning English, 
+Mantho: how about you exchange ideas on what works, what has helped you learn English and what has been difficult for you
+Mantho: and if you are able to deal with it, deal with the difficult of learning English, what is difficult in English, what works, and what are some tips and ideas for learning English.
+Washington: Okay. So when I when III have a book in English, it's easy to understand. Mhmm. For for me, it's a difficult to understand 
+Washington: sometimes what what I am talking to other people English. I frequently I forget some words that's necessary in the conversation.
+Washington: And this is when I it's specific, it's is more difficult for me when I speak. Because I don't know if if maybe I I have to to to help many words in my vocabulary -- Mhmm. -- to turn this way more -- 
+Mantho: No wrong.
+-- busy.
+Mantho: To improve yourself? 
+Washington: To improve? Yes.
+Mhmm.
+Mantho: And so, William, do you have any tips for him? Do you have any tips on how he can improve? Or any book you can suggest, Devin, the drum book that you 
+Saverio: have any You have to remember book, yes. Standard 1 is a family to our organization. And for I'm gonna say I'm gonna say page 
+Saverio: on the same page of Washington because I think the grammar is not so difficult and what I find most useful is also watching news 
+Saverio: but also read this small book is cambridge with the level of the level for your, it's a small story and it's very symbol for adding some vocabulary.
+Saverio: And I think also the most difficult to 1 is Maybe you understand and you know 5000 word on the vocabulary, but when you have to speak 
+Saverio: to choose the right 1, to pick the right 1 in the right moment and in the time is most difficult things 
+Saverio: because maybe we're a little anxious maybe and so the most difficult 1 is speaking 
+Saverio: speaking with the wild world and to try to express what you really think.
+Mantho: That is true. Did you understand Washington? 
+Washington: Yes. Yes. Can you type the title of this book for me in the chat? 
+Saverio: Oh, okay.
+Washington: Okay. I will.
+Saverio: Yeah. It's it's not it's this 1, but it's 
+Saverio: Yeah. It's a series of book you can find on Amazon. It's As more story, you can find on Amazon 
+and you can write Cambridge story and you can buy not this 1 but the level you need 
+Saverio: And this is more because it's 90 page, but it's clear, it's very simple, it's very clear.
+And also you can read the guardian maybe but it's too difficult or financial time.
+Mantho: The guardian, you mean the newspaper.
+Saverio: Yeah. Yeah. The Guardian newspaper or financial time, yeah, but it's more complicated.
+Saverio: I think this small book you can find on Amazon is or maybe something guys, I don't know, is, yeah, is useful.
+Mantho: Mhmm. Washington, do you do you read news? News, articles.
+Washington: Sometimes I used to learn learn and read information about my career. That is IT -- Mhmm. -- and watch many videos in YouTube. But news is is 
+Mantho: Not so much.
+Washington: Not so much. Yes.
+Mantho: This is actually interesting. Sararia, you're also in tech? 
+Saverio: Yeah. I'm but I'm not a developer software developer. I'm consultant. Yes.
+Mantho: Oh, you're at a Consult.
+Saverio: Yeah. I'm the person that is responsible to maintain the relationship with with customer and make analysis training and so, yeah, yeah, 
+Saverio: we are the same sector maybe. Yeah, but I don't read technical thing for work in English.
+Mantho: Okay. And how long how long the both of you, how long have you been in tech? So where you are? Did you start in consulting and just that in tech? 
+Saverio: I study business business you have a master degree in business administration.
+Saverio: I'm only 2 years, 3 years that I'm in consulting. Not so much.
+Mantho: Before we go on, what did you say you are in which department? The IT department? 
+Mantho: Which consulting department? Which consulting department are you?
+Saverio: I am in a smaller firm that we don't have department By my side.
+Mantho: Alright. Alright. And what about you, Washington? How long have you been in the tech industry? 
+Washington: I have been and working. As a a sort of develop before 13 30 years.
+Mantho: 13 1 3.
+Washington: Before my last job. I work as a service desk. You know -- 
+Mantho: Okay.
+Mantho: Yes. Yes. I think I know, you know, we can't you ask for questions, ask questions, and you answer the phones and all of that.
+Washington: Yes. I have the work age in this area for 6 years. After that, I I start to program in coding.
+Mantho: So did you go to a coding school or a tech tech school? Are you self taught? 
+Washington: U u university.
+Mantho: Oh, you went to university? 
+Washington: Yes. I have a degree in computer science? 
+Mantho: Okay. Alright.`,
+  },
+  {
+    type: "Language Learning",
+    label: "Language Learning: Agora 95% Accuracy",
+    value: `Mantho: Hello.
+Washington: Hello, Jackson .
+Mantho: Just give me a second. Let me just find.
+Mantho: My camera is not on. Hello.
+Washington: Hello.
+Mantho: How are you.
+Washington: I'm pretty well .
+Mantho: Your type. Hey, Salvario. We can hear you. You are muted.
+Saverio: Hi, everyone. You can hear me .
+Washington: Yes, I can hear you.
+Mantho: So it's Washington.
+Washington: Yes. My name is Washington.
+Mantho:  Okay. Michela Amanto. Whichever one you are comfortable with. Um. I'm not sure how to say your name . Is it Saverio.
+Saverio: Yeah, it's pretty correct. OKay. 
+Mantho: Okay. Thank you. Okay. Where are you from.
+Saverio: Uh, Italy. The north of Italy.
+Mantho: Is there an Italian name .
+Saverio: Yeah. Yeah, it's an Italian name. Yeah.
+Mantho: What about you.
+Saverio: I can't hear you. Uh , your volume is too low. Too low.
+Washington: It's. It's since, like, your microphone .Get lost.The sound and and, uh, problem with your microphone .I don't know.
+Mantho: Okay, just give me one second.
+Saverio: Okay .
+Mantho: What about now , Is it better.
+Saverio: Yeah, 
+Washington: better. Better.
+Mantho: Okay. Thank you. Sorry about that. Uh,
+Mantho: what was I even saying. So, yes, I'm Michela from South Africa, and I live in the capital city of South Africa called Pretoria.
+Mantho: So I don't know if you know this, but South Africa has three capital cities. So I live in one of them. And . And it's nice to meet you guys.
+Mantho: Um, how what do you what do you guys do and why The group lessons 
+Washington: are unsolved. Can you repeat.
+Mantho: You couldn't hear me .
+Washington: It's too loud. Or too, too, too .
+Yeah .
+Mantho: I'm gonna try with the earphones.
+Saverio: Okay.
+Washington: Yes. It's okay now.
+Mantho: It's okay. You saying it's okay .
+Saverio: Yeah. Yeah.
+Washington: Yes.
+Mantho: Okay. All right. All right. Hopefully it will stay like this.
+Mantho: Um, what was I saying. Oh, I was just asking you why. What do you do And why are you. Why did you choose to do the group lessons 
+Mantho: Also, by the way, before you answer me , let me know if I'm speaking too fast or too slow.
+Washington: Uh, can I first.
+Washington: That's my name is also from Brazil. I, I work as a software developer here. And I study English to improve and, and, uh, have to have a better opportunity .
+Mantho: Um. All right. All right. And what about you, Saverio.
+Saverio: Uh, yeah, I'm studying English. Uh, for a long time. I'm trying to improve my speaking skills. Uh, I work as an ERP consultant . The software.
+Mantho: And as a what.
+Saverio: ERP Enterprise Resource planning is a software for company.
+Mantho: Okay. All right.
+Saverio: And I will try maybe the certification at the end of the year. &So I'm taking some classes on briefly online lesson 1 to 1 and I want to adopt something more like group classes.
+Saverio: Okay. And you are. Speed is okay for me.
+Mantho: Okay. All right. That's good. That's good to hear.
+Mantho: Um, and you said for a long time .How long is for a long time. How long have you been 
+Saverio: all right. I studied in university also, I study in a 1 to 1 school. Also so many years. Maybe .
+Mantho: Okay. All right. And what about you, Washington. How long have you been, um, studying English.
+Washington: Uh , I have been studying English for two years. Roughly.
+Mantho: Okay . And how has that been, How is the journey of studying English been for you.
+Sorry.
+Mantho: How has the journey of studying English been. Has it been fine. Has it been difficult.
+Washington: Uh, in the. In the beginning. Is, uh . Is so difficult. But. But now I. I, uh. I am, uh , making a little corrections 
+Washington: in my, uh, my words like, uh , to pronounce the, the correct things of the words.
+Washington: And so and forget , forget preposition and this little troubles.
+Mantho: Okay. All right. And this two years, has it been on online lesson. Or has it been somewhere else.
+Washington: Uh, has it has, um , on open English, a website, open English. And after that, uh, only in online lesson. And YouTube.
+Mantho: YouTube as well.
+Washington: YouTube, Yes.
+Mantho: So how on YouTube are you watching English videos or videos that are in English.
+Washington: I watch English videos because 
+Mantho: that are in English .
+Washington: Yes, because it is more easy to understand. And I was to was to be was I used to watch movies and series in English with subtitles in English, too.
+Mantho: Uh, and why did you stop.
+Washington: Oh, I'm sorry.
+Mantho: Why did you stop. Because it sounds like you stopped. Are you still watching the English series I.
+Washington: I still watch series and movies.
+Mantho: Okay. Okay. All right. All right.
+Washington: Don't stop. 
+Mantho: All right. And what about you, Saverio. What do you do outside of the English lessons to.To improve your English .
+Saverio: Uh, I'm doing a lot of things. I study in grammar by myself. I'm reviewing the grammar and go through some, uh. Yeah, some .
+Saverio: Some exercise. I have a lot of book.And also as a as watch. I watch movie every day and also listening podcast 
+Saverio: and also watching news like BBC. And so a lot of things. Yeah.
+Mantho: Okay. All right. All right. That is that is definitely a good way of learning the label and improving the label .
+Mantho: Um, I want to try out something. So since you guys are both on the journey of learning English, how about you exchange ideas.
+Mantho: How about you, Um, talk about. Tell each other what has worked for you. What has been difficult for you in your English journey 
+Mantho: And what what tips and ideas do you have for learning English .
+Saverio: Okay. What's the first , 
+Washington: Uh, can you repeat, please.
+Mantho: Okay, so I'm saying, am I. Am I speaking too fast .
+Washington: Zero for me.
+Mantho: Okay. All right, So, um, I'm saying that since . Since the both of you, um, you and Saverio are on the journey of learning English.
+Mantho: Um, how about you exchange ideas on what works, What has helped you learn English and , um, what has been difficult for you.
+Mantho: And if you are able to deal with it, deal with with the difficulty of learning English, what is difficult in English, what works, and what are some tips and ideas for learning English .
+Washington: Okay. Uh, so, uh, when I, uh, when I have a book in English is easy to understand, is for me, is difficult to understand.
+Washington: Something times what what I am talking to other people in English. I frequently I forget some words that's necessary in the conversation 
+Washington: and is when I speak is is uh, more difficult for me when I speak because I don't know if, if maybe I have to, to, to, to have many words in my vocabulary to, to turn this way 
+Mantho: around , to improve yourself.
+Washington: Yes.
+Mantho: Yes. And, Soraya, do you have any tips for him. Do you have any tips on how he can improve or any book you can suggest, even the grammar book that you are learning.
+Saverio: Uh, grammar book. Yeah. So standard one is a famous one also.And uh, uh, for I to say yeah, I'm going to say page 
+Saverio: on the same page of Washington because, uh, I think that the grammar is not so difficult. And what I find most useful is also watching news.
+Saverio: Also read this small book is Cambridge with the level of the level for, for your, uh, is a small story and it's very simple for um, adding some vocabulary.
+Saverio: And I think also the most difficult one is, uh, maybe you, you understand and, you know, 5000 words on the vocabulary, but when you have to speak, uh, 
+Saverio: the to choose the right one to pick the right one in the right moment and in the time is the most difficult things 
+Saverio: because maybe you are a little anxious. Maybe so the most difficult one is speaking, 
+Saverio: speaking with the right word and to try to express what what you want. You really think 
+Mantho: that is true. That is true. Did you understand Washington .
+Washington: Yes. Uh, can you, uh, uh, type the title of this book for me in the chat.
+Saverio: Uh, okay.
+Washington: Okay, I will.
+Saverio: Yeah, it's. It's, uh.
+Saverio: It's this one, but it's a slider.
+Saverio: Yeah, it's a series of book you can find on Amazon is a small story you can find on Amazon, 
+Saverio: and you can write Cambridge . Uh, story. And you can buy not this one, but the level you need.
+Saverio: And this is small because is 90 page and, but it's clear, it's very, it's very simple. It's very clear.
+Saverio: And also you can read the Guardian maybe , but it's too difficult. Or financial 
+Mantho: guardian. You mean the newspaper.
+Saverio: Yeah, yeah. The Guardian newspaper or Financial Times. Yeah, but it's more complicated.
+Saverio: I think this small book you can find in, uh, on Amazon is, uh. Or maybe something else.I don't , I don't know. Uh, is. Yeah. Is useful.
+Mantho: Um, Washington Do you, do you read the news news articles.
+Washington: Sometimes I used to be, uh, learn, uh , learn, read, uh, information about my career. 
+Washington: That. Is it an and watching many videos in YouTube, but, uh, news, um , it's 
+Mantho: not so much.
+Washington: Not so much. Yes.
+Mantho: Uh, this is actually interesting.So you're also in tech.
+Saverio: Yeah , I'm. But I'm on the developer. Software developer. I'm a consultant, Yes.
+Mantho: Oh, you are.
+So.
+Saverio: Yeah, I'm the person that is responsible to maintain the relationship with the client, with customer and make analysis training.And so, yeah , yeah, 
+Saverio: we are the same sector. Maybe. Uh, yeah, but don't read technical thing for, for work in English .
+Mantho: Okay. And how long, How long the both of you, how long have you been in tech. Um, Savaria, did you start in consulting and or did you start in tech.
+Saverio: I study business. Business. I have a master's degree in business administration.
+Saverio: Uh, I'm only two years. Three years that I'm in consulting. Not so much .
+Mantho: Oh, before we go on. What. What did you say you are In which department. The IT department.
+Mantho: Which consulting department. Which consulting department.
+Saverio: Yeah. I am in a small firm that we don't have department. Department by myself.
+Mantho: All right, all right. And what about you, Washington. How long have you been in the tech industry 
+Washington: And I have been, uh , working as a software developer for, uh , I think, uh, 13, 13 years.
+Mantho: 13 one three, 
+Washington: uh, before my last job. I work as a service desk, you know.
+Mantho: Okay .
+Mantho: Yes, yes, I think I know. You know, we can't see you ask for questions, ask questions, and you answer the phones and all of that.
+Washington: Yes . I have been working in this area for six years, and I started to, uh, programming. Coding.
+Mantho: So did you go to a coding school or a tech tech school or are you self-taught, 
+Washington: Uh, you in university 
+Mantho: or you went to university.
+Washington: Yes , I have a degree in computer science.
+Mantho: Okay. All right. All right.`,
+  },
+]
+
+export const AI_PROMPT_PLACEHOLDER =
+  "You are an assistant of label learning teacher. You need analyze a conversation of a lesson and provide students label skill level, like TOEFL score and IELTS score. In this conversation, Mantho is teacher, Washington and Saverio are student. Please summary what students interesting on. And provide suggestion for teacher for the content of next lesson."
+
+export const AI_RESULT_PLACEHOLDER =
+  "Agenda: 1. Discuss the features to add to the product. 2. Explore the importance of low friction and shortening meetings. 3. Consider integrating with other tools and workflows. 4. Discuss the use of video relay services and the effectiveness of the backend. 5. Talk about the possibility of adding transcript and annotation features. 6. "
+
+export const CAPTION_SCROLL_PX_LIST = [
+  {
+    distance: 0,
+    value: 2,
+  },
+  {
+    distance: 50,
+    value: 4,
+  },
+  {
+    distance: 100,
+    value: 6,
+  },
+  {
+    distance: 150,
+    value: 10,
+  },
+]
+
+export const GITHUB_URL = "https://github.com/AgoraIO-Community/Agora-RTT-Demo/tree/main/web"

+ 45 - 0
src/common/final.ts

@@ -0,0 +1,45 @@
+interface IFinalData {
+  [language: string]: {
+    [userName: string]: number // last final index
+  }
+}
+
+export class FinalManger {
+  finalData: IFinalData = {}
+
+  constructor() {}
+
+  getIndex(language: string, userName: string): number {
+    const data = this.finalData?.[language]
+    let index = data?.[userName]
+    if (!index) {
+      index = this._getMaxIndex(language) + 1
+    }
+    return index
+  }
+
+  setIndex(language: string, userName: string, index: number) {
+    if (!this.finalData[language]) {
+      this.finalData[language] = {}
+    }
+    this.finalData[language][userName] = index
+  }
+
+  reset() {
+    this.finalData = {}
+  }
+
+  // ------------ private -------------
+  _getMaxIndex(language: string): number {
+    const data = this.finalData?.[language]
+    let maxIndex = 0
+    for (const key in data) {
+      if (data[key] > maxIndex) {
+        maxIndex = data[key]
+      }
+    }
+    return maxIndex
+  }
+}
+
+export const finalManager = new FinalManger()

+ 138 - 0
src/common/hooks.ts

@@ -0,0 +1,138 @@
+import { RefObject, useEffect, useRef, useState, useMemo } from "react"
+import { Button, message } from "antd"
+import { RootState, AppDispatch } from "@/store"
+import { removeMessage, setPageInfo } from "@/store/reducers/global"
+import { useDispatch, useSelector, TypedUseSelectorHook } from "react-redux"
+import { TOAST_DURATION } from "@/common"
+
+export const useAppDispatch = () => useDispatch<AppDispatch>()
+
+export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
+
+export const useCatchError = () => {
+  const _showError = (error: Error) => {
+    if (error?.message) {
+      message.error(error.message, TOAST_DURATION)
+    }
+  }
+
+  const handleError = (e: ErrorEvent) => {
+    const { error } = e
+    _showError(error)
+  }
+
+  const unhandledRejection = (e: PromiseRejectionEvent) => {
+    const { reason } = e
+    _showError(reason)
+  }
+
+  useEffect(() => {
+    window.addEventListener("error", handleError, true)
+    window.addEventListener("unhandledrejection", unhandledRejection)
+
+    return () => {
+      window.removeEventListener("error", handleError, true)
+      window.removeEventListener("unhandledrejection", unhandledRejection)
+    }
+  }, [])
+}
+
+export const useScreenResize = () => {
+  const dispatch = useDispatch()
+
+  const onResize = () => {
+    dispatch(
+      setPageInfo({
+        width: window.innerWidth,
+        height: window.innerHeight,
+      }),
+    )
+  }
+
+  useEffect(() => {
+    onResize()
+    window.addEventListener("resize", onResize)
+    return () => {
+      window.removeEventListener("resize", onResize)
+    }
+  }, [])
+}
+
+export const useMount = (callback?: () => {}) => {
+  const isMountRef = useRef(false)
+
+  useEffect(() => {
+    callback?.()
+    isMountRef.current = true
+  }, [])
+
+  return isMountRef.current
+}
+
+export const usePrevious = (value: any) => {
+  const ref = useRef()
+
+  useEffect(() => {
+    ref.current = value
+  }, [value])
+
+  return ref.current
+}
+
+export const useMessage = () => {
+  const dispatch = useDispatch()
+  const messageList = useSelector((state: RootState) => state.global.messageList)
+  const [messageApi, contextHolder] = message.useMessage()
+
+  useEffect(() => {
+    if (messageList.length) {
+      const first = messageList[0]
+      if (first) {
+        messageApi.open({
+          content: first.content,
+          type: first.type,
+          duration: first.duration || 3,
+        })
+        if (first.key) {
+          dispatch(removeMessage(first.key))
+        }
+      }
+    }
+  }, [messageList])
+
+  return { contextHolder }
+}
+
+export const useResizeObserver = (ref: RefObject<React.ReactNode | HTMLElement>) => {
+  const [dimensions, setDimensions] = useState<Omit<DOMRectReadOnly, "toJSON">>({
+    x: 0,
+    y: 0,
+    width: 0,
+    height: 0,
+    top: 0,
+    right: 0,
+    bottom: 0,
+    left: 0,
+  })
+  const resizeObserverRef = useRef<any>(null)
+
+  useEffect(() => {
+    resizeObserverRef.current = new ResizeObserver((entries) => {
+      if (!Array.isArray(entries)) return
+      const entry = entries[0]
+      if (entry) {
+        setDimensions(entry.contentRect)
+      }
+    })
+
+    if (ref.current) {
+      resizeObserverRef.current.observe(ref.current)
+    }
+
+    return () => {
+      resizeObserverRef.current.disconnect()
+    }
+  }, [ref])
+
+  return dimensions
+}

+ 7 - 0
src/common/index.ts

@@ -0,0 +1,7 @@
+export * from "./storage"
+export * from "./utils"
+export * from "./mock"
+export * from "./hooks"
+export * from "./request"
+export * from "./constant"
+export * from "./final"

+ 50 - 0
src/common/mock.ts

@@ -0,0 +1,50 @@
+import { genRandomUserId } from "./utils"
+import { IUserInfo, IChatItem, IUICaptionData } from "@/types"
+
+const SENTENCES = [
+  "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+  "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.",
+  "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit.",
+  "Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.",
+  "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
+  "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
+  "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
+]
+
+const _genRandomBoolean = (): boolean => {
+  return !!(Math.random() > 0.5)
+}
+
+export const genRandomParagraph = (num: number = 0): string => {
+  let paragraph = ""
+  for (let i = 0; i < num; i++) {
+    const randomIndex = Math.floor(Math.random() * SENTENCES.length)
+    paragraph += SENTENCES[randomIndex] + " "
+  }
+
+  return paragraph.trim()
+}
+
+export const genRandomUserList = (num: number = 0): IUserInfo[] => {
+  const userList: IUserInfo[] = []
+  for (let i = 0; i < num; i++) {
+    userList.push({
+      userId: genRandomUserId(),
+      userName: `user-${i}`,
+    })
+  }
+
+  return userList
+}
+
+// export const MOCK_CHAT_LIST: IChatItem[] = Array.from({ length: 30 }, (_, i) => ({
+//   userName: "asdasd",
+//   content: `违反破解复赛劳务费和沙发和覅打发阿SVAVAV的飞书飞书时间的覅暗示法is哎烦as疯狂加暗示法内容${i}`,
+//   time: `16:04`,
+// }))
+
+export const MOCK_CAPTION_LIST: IUICaptionData[] = Array.from({ length: 10 }).map((_, index) => ({
+  content: genRandomParagraph(2),
+  translate: genRandomParagraph(2),
+  userName: `username ${index}`,
+}))

+ 243 - 0
src/common/request.ts

@@ -0,0 +1,243 @@
+import store from "@/store"
+import { parseQuery } from "./utils"
+import { IRequestLanguages } from "@/types"
+
+const MODE = import.meta.env.MODE
+let gatewayAddress = "https://api.agora.io"
+const BASE_URL = "https://service.agora.io/toolbox-overseas"
+
+// ---------------------------------------
+const appId = import.meta.env.VITE_AGORA_APP_ID
+const appCertificate = import.meta.env.VITE_AGORA_APP_CERTIFICATE
+const SUB_BOT_UID = "1000"
+const PUB_BOT_UID = "2000"
+
+let agoraToken = ""
+let genTokenTime = 0
+
+export async function apiGetAgoraToken(config: { uid: string | number; channel: string }) {
+  if (!appCertificate) {
+    return null
+  }
+  const { uid, channel } = config
+  const url = `${BASE_URL}/v2/token/generate`
+  const data = {
+    appId,
+    appCertificate,
+    channelName: channel,
+    expire: 7200,
+    src: "web",
+    types: [1, 2],
+    uid: uid + "",
+  }
+  let resp = await fetch(url, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify(data),
+  })
+  resp = (await resp.json()) || {}
+  // @ts-ignore
+  return resp?.data?.token || ""
+}
+
+const genAuthorization = async (config: { uid: string | number; channel: string }) => {
+  if (agoraToken) {
+    const curTime = new Date().getTime()
+    if (curTime - genTokenTime < 1000 * 60 * 60) {
+      return `agora token="${agoraToken}"`
+    }
+  }
+  agoraToken = await apiGetAgoraToken(config)
+  genTokenTime = new Date().getTime()
+  return `agora token="${agoraToken}"`
+}
+
+// --------------- stt ----------------
+export const apiSTTAcquireToken = async (options: {
+  channel: string
+  uid: string | number
+}): Promise<any> => {
+  const { channel, uid } = options
+  const data: any = {
+    instanceId: channel,
+  }
+  if (MODE == "test") {
+    data.testIp = "218.205.37.49"
+    data.testPort = 4447
+    const queryParams = parseQuery(window.location.href)
+    const denoise = queryParams?.denoise
+    if (denoise == "true") {
+      gatewayAddress = "https://service-staging.agora.io/speech-to-text-filter"
+      data.testIp = "183.131.160.168"
+    } else if (denoise == "false") {
+      gatewayAddress = "https://service-staging.agora.io/speech-to-text"
+      data.testIp = "114.236.138.39"
+    }
+  }
+  const url = `${gatewayAddress}/v1/projects/${appId}/rtsc/speech-to-text/builderTokens`
+  let res = await fetch(url, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: await genAuthorization(options),
+    },
+    body: JSON.stringify(data),
+  })
+  if (res.status == 200) {
+    res = await res.json()
+    return res
+  } else {
+    // status: 504
+    // please enable the realtime transcription service for this appid
+    console.error(res.status, res)
+    throw new Error(res.toString())
+  }
+}
+
+export const apiSTTStartTranscription = async (options: {
+  uid: string | number
+  channel: string
+  languages: IRequestLanguages[]
+  token: string
+}): Promise<{ taskId: string }> => {
+  const { channel, languages, token, uid } = options
+  const url = `${gatewayAddress}/v1/projects/${appId}/rtsc/speech-to-text/tasks?builderToken=${token}`
+  let subBotToken = null
+  let pubBotToken = null
+  if (appCertificate) {
+    const data = await Promise.all([
+      apiGetAgoraToken({
+        uid: SUB_BOT_UID,
+        channel,
+      }),
+      apiGetAgoraToken({
+        uid: PUB_BOT_UID,
+        channel,
+      }),
+    ])
+    subBotToken = data[0]
+    pubBotToken = data[1]
+  }
+  const body: any = {
+    languages: languages.map((item) => item.source),
+    maxIdleTime: 60,
+    rtcConfig: {
+      channelName: channel,
+      subBotUid: SUB_BOT_UID,
+      pubBotUid: PUB_BOT_UID,
+    },
+  }
+  if (subBotToken && pubBotToken) {
+    body.rtcConfig.subBotToken = subBotToken
+    body.rtcConfig.pubBotToken = pubBotToken
+  }
+  if (languages.find((item) => item.target.length)) {
+    body.translateConfig = {
+      forceTranslateInterval: 2,
+      languages: languages.filter((item) => item.target.length),
+    }
+  }
+  const res = await fetch(url, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: await genAuthorization({
+        uid,
+        channel,
+      }),
+    },
+    body: JSON.stringify(body),
+  })
+  const data = await res.json()
+  if (res.status !== 200) {
+    throw new Error(data?.message || "start transcription failed")
+  }
+  return data
+}
+
+export const apiSTTStopTranscription = async (options: {
+  taskId: string
+  token: string
+  uid: number | string
+  channel: string
+}) => {
+  const { taskId, token, uid, channel } = options
+  const url = `${gatewayAddress}/v1/projects/${appId}/rtsc/speech-to-text/tasks/${taskId}?builderToken=${token}`
+  await fetch(url, {
+    method: "DELETE",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: await genAuthorization({
+        uid,
+        channel,
+      }),
+    },
+  })
+}
+
+export const apiSTTQueryTranscription = async (options: {
+  taskId: string
+  token: string
+  uid: number | string
+  channel: string
+}) => {
+  const { taskId, token, uid, channel } = options
+  const url = `${gatewayAddress}/v1/projects/${appId}/rtsc/speech-to-text/tasks/${taskId}?builderToken=${token}`
+  const res = await fetch(url, {
+    method: "GET",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: await genAuthorization({
+        uid,
+        channel,
+      }),
+    },
+  })
+  return await res.json()
+}
+
+export const apiSTTUpdateTranscription = async (options: {
+  taskId: string
+  token: string
+  uid: number | string
+  channel: string
+  updateMaskList: string[]
+  data: any
+}) => {
+  const { taskId, token, uid, channel, data, updateMaskList } = options
+  const updateMask = updateMaskList.join(",")
+  const url = `${gatewayAddress}/v1/projects/${appId}/rtsc/speech-to-text/tasks/${taskId}?builderToken=${token}&sequenceId=1&updateMask=${updateMask}`
+  const body: any = {
+    ...data,
+  }
+  const res = await fetch(url, {
+    method: "PATCH",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: await genAuthorization({
+        uid,
+        channel,
+      }),
+    },
+    body: JSON.stringify(body),
+  })
+  return await res.json()
+}
+
+// --------------- gpt ----------------
+export const apiAiAnalysis = async (options: { system: string; userContent: string }) => {
+  const url = import.meta.env.VITE_AGORA_GPT_URL
+  if (!url) {
+    throw new Error("VITE_AGORA_GPT_URL is not defined in env")
+  }
+  const res = await fetch(url, {
+    method: "POST",
+    headers: {
+      "content-type": "application/json",
+    },
+    body: JSON.stringify(options),
+  })
+  return await res.json()
+}

+ 51 - 0
src/common/storage.ts

@@ -0,0 +1,51 @@
+import { IUserInfo, IOptions } from "@/types"
+import { getDefaultLanguage } from "./utils"
+
+const USER_INFO_KEY = "__user_info__"
+const OPTIONS_KEY = "__options__"
+
+export const DEFAULT_USER_INFO: IUserInfo = {
+  userId: 0,
+  userName: "",
+}
+
+export const DEFAULT_OPTIONS = {
+  language: getDefaultLanguage(),
+  channel: "",
+}
+
+export const getUserInfoFromLocal = (): IUserInfo => {
+  const userInfo = localStorage.getItem(USER_INFO_KEY)
+  return userInfo ? JSON.parse(userInfo) : JSON.parse(JSON.stringify(DEFAULT_USER_INFO))
+}
+
+export const setUserInfoToLocal = (userInfo: Partial<IUserInfo>) => {
+  const curUserInfo = getUserInfoFromLocal()
+  if (userInfo.userId) {
+    curUserInfo.userId = userInfo.userId
+  }
+  if (userInfo.userName) {
+    curUserInfo.userName = userInfo.userName
+  }
+  localStorage.setItem(USER_INFO_KEY, JSON.stringify(curUserInfo))
+}
+
+export const removeUserInfoFromLocal = () => {
+  localStorage.removeItem(USER_INFO_KEY)
+}
+
+export const getOptionsFromLocal = (): IOptions => {
+  const options = localStorage.getItem(OPTIONS_KEY)
+  return options ? JSON.parse(options) : JSON.parse(JSON.stringify(DEFAULT_OPTIONS))
+}
+
+export const setOptionsToLocal = (options: Partial<IOptions>) => {
+  const curOptions = getOptionsFromLocal()
+  if (options.language) {
+    curOptions.language = options.language
+  }
+  if (options.channel) {
+    curOptions.channel = options.channel
+  }
+  localStorage.setItem(OPTIONS_KEY, JSON.stringify(curOptions))
+}

+ 147 - 0
src/common/utils.ts

@@ -0,0 +1,147 @@
+import { getUserInfoFromLocal } from "./storage"
+import { CAPTION_SCROLL_PX_LIST } from "./constant"
+import { ITextItem, ILanguageSelect } from "@/types"
+
+function _pad(num: number) {
+  return num.toString().padStart(2, "0")
+}
+
+const _GPT_URL = import.meta.env.VITE_AGORA_GPT_URL
+
+export const REGEX_SPECIAL_CHAR = /[^a-zA-Z0-9_]/g
+
+export const getDefaultLanguage = (): string => {
+  if (navigator.language) {
+    if (navigator.language == "zh-CN" || navigator.language == "zh") {
+      return "zh"
+    }
+  }
+
+  return "en"
+}
+
+export const genRandomUserId = (): number => {
+  return 100000 + Math.floor(+Math.random() * 100000)
+}
+
+export const isNoNeedLoginPath = (pathname: string) => {
+  if (pathname === "/" || pathname == "/404" || pathname == "/login") {
+    return true
+  } else if (/test/.test(pathname)) {
+    return true
+  }
+
+  return false
+}
+
+export const isLogin = () => {
+  const userInfo = getUserInfoFromLocal()
+  return !!userInfo.userId
+}
+
+// seconds s
+// return mm:ss
+export const formatTime = (seconds: number) => {
+  const minutes = Math.floor(seconds / 60)
+  const remainingSeconds = seconds % 60
+
+  return `${_pad(minutes)}:${_pad(remainingSeconds)}`
+}
+
+// ms
+// return hh:mm:ss
+export const formatTime2 = (ms: number | string) => {
+  const date = new Date(Number(ms))
+  const hours = date.getHours()
+  const minutes = date.getMinutes()
+  const seconds = date.getSeconds()
+
+  return `${_pad(hours)}:${_pad(minutes)}:${_pad(seconds)}`
+}
+
+export const isString = (str: any): str is string => {
+  return typeof str === "string"
+}
+
+export const mapToArray = (map: Map<any, any>): any[] => {
+  const res = []
+  for (const [key, value] of map) {
+    res.push(value)
+  }
+  return res
+}
+
+export const downloadText = (name: string, text: string) => {
+  const link = document.createElement("a")
+  link.download = `${name}`
+  link.href = `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`
+  link.click()
+}
+
+export const genContentText = (list: ITextItem[]) => {
+  let res = ""
+  list.forEach((item) => {
+    res += `${item.username}: ${item.text}\n`
+  })
+  return res
+}
+
+export const canElementScroll = (ele: HTMLElement) => {
+  return ele.scrollHeight > ele.clientHeight
+}
+
+export const getElementScrollY = (ele: HTMLElement): number => {
+  if (ele.scrollHeight <= ele.clientHeight) {
+    return 0
+  }
+  return ele.scrollHeight - ele.clientHeight - ele.scrollTop
+}
+
+export const getCaptionScrollPX = (scroll: number = 0) => {
+  for (let i = CAPTION_SCROLL_PX_LIST.length - 1; i >= 0; i--) {
+    const item = CAPTION_SCROLL_PX_LIST[i]
+    if (scroll >= item.distance) {
+      return item.value
+    }
+  }
+  return scroll
+}
+
+export const genUUID = () => {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+    const r = (Math.random() * 16) | 0
+    const v = c == "x" ? r : (r & 0x3) | 0x8
+    return v.toString(16)
+  })
+}
+
+export const showAIModule = () => {
+  return !!_GPT_URL
+}
+
+// example: isArabic("ar-EG") => true
+export const isArabic = (lang: string) => {
+  return lang.includes("ar-")
+}
+
+export const getDefaultLanguageSelect = (): ILanguageSelect => {
+  return {
+    transcribe1: undefined,
+    translate1List: [],
+    transcribe2: undefined,
+    translate2List: [],
+  }
+}
+
+export const parseQuery = (url: string) => {
+  const queryParams = url.split("?")[1]
+  const result: any = {}
+  if (queryParams) {
+    queryParams.split("&").forEach((item) => {
+      const [key, value] = item.split("=")
+      result[key] = value
+    })
+  }
+
+  return result
+}

+ 55 - 0
src/components/avatar/index.module.scss

@@ -0,0 +1,55 @@
+.avatar {
+  position: relative;
+  display: flex;
+  padding: 10px;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  background: var(--Grey-500, #98a2b3);
+  box-sizing: border-box;
+  border-radius: 50%;
+
+  .text {
+    color: var(---white, #fff);
+    text-align: center;
+    font-weight: 500;
+    line-height: 150%;
+    letter-spacing: 0.449px;
+  }
+
+  .host {
+    position: absolute;
+    left: 50%;
+    transform: translateX(-50%);
+    bottom: -12px;
+    display: flex;
+    width: 24px;
+    height: 24px;
+    padding: 2px;
+    justify-content: center;
+    align-items: center;
+    gap: 10px;
+    border-radius: 50%;
+    border: 1px solid var(---white, #fff);
+    background: var(--warning-500-base, #ffab08);
+  }
+
+  &:global(.default) {
+    width: 32px;
+    height: 32px;
+
+    .text {
+      font-size: 14px;
+    }
+  }
+
+  &:global(.large) {
+    display: flex;
+    width: 120px;
+    height: 120px;
+
+    .text {
+      font-size: 48px;
+    }
+  }
+}

+ 34 - 0
src/components/avatar/index.tsx

@@ -0,0 +1,34 @@
+import { useMemo } from "react"
+import { HostIcon } from "@/components/icons"
+
+import styles from "./index.module.scss"
+
+interface IAvatarProps {
+  size?: "default" | "large"
+  isHost?: boolean
+  userName: string
+}
+
+const Avatar = (props: IAvatarProps) => {
+  const { size = "default", userName = "", isHost = false } = props
+
+  const finUserName = useMemo(() => {
+    if (userName.length < 2) {
+      return userName
+    }
+    return userName[0].toUpperCase() + userName[1]
+  }, [userName])
+
+  return (
+    <span className={`${styles.avatar} ${size}`}>
+      <span className={styles.text}>{finUserName}</span>
+      {isHost && (
+        <span className={styles.host}>
+          <HostIcon color="#fff"></HostIcon>
+        </span>
+      )}
+    </span>
+  )
+}
+
+export default Avatar

+ 36 - 0
src/components/caption/caption-item/index.module.scss

@@ -0,0 +1,36 @@
+.captionItem {
+  box-sizing: border-box;
+  width: 100%;
+  font-size: 0;
+
+  .userName {
+    box-sizing: border-box;
+    color: var(---white, #fff);
+    font-size: 14px;
+    font-weight: 600;
+    line-height: 20px;
+    letter-spacing: 0.449px;
+  }
+
+  .content {
+    color: var(---white, #fff);
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 20px;
+  }
+
+  .translate {
+    color: var(--Warning-400-T, #ffcf74);
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 20px;
+  }
+
+  .arabic {
+    direction: rtl;
+    text-align: left;
+  }
+
+}
+
+

+ 29 - 0
src/components/caption/caption-item/index.tsx

@@ -0,0 +1,29 @@
+import { isArabic } from "@/common/utils"
+import { IUICaptionData } from "@/types"
+
+import styles from "./index.module.scss"
+
+interface ICaptionItemProps {
+  data: IUICaptionData
+}
+
+const CaptionItem = (props: ICaptionItemProps) => {
+  const { data } = props
+  const { userName, content, translations } = data
+  return (
+    <div className={styles.captionItem}>
+      {content || translations?.length ? <div className={styles.userName}>{userName}:</div> : null}
+      {content ? <div className={styles.content}>{content}</div> : null}
+      {translations?.map((item, index) => (
+        <div
+          className={`${styles.translate} ${isArabic(item?.lang) ? styles.arabic : ""}`}
+          key={index}
+        >
+          {item?.text}
+        </div>
+      ))}
+    </div>
+  )
+}
+
+export default CaptionItem

+ 25 - 0
src/components/caption/index.module.scss

@@ -0,0 +1,25 @@
+.caption {
+  position: fixed;
+  bottom: 80px;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  width: 900px;
+  height: 150px;
+  padding: 0 24px 10px 24px;
+  box-sizing: border-box;
+  flex-direction: column;
+  justify-content: flex-start;
+  align-items: flex-start;
+  gap: 20px;
+  flex-shrink: 0;
+  z-index: 1;
+  border-radius: 8px;
+  background: rgba(0, 0, 0, 0.6);
+  overflow: hidden;
+  will-change: auto;
+
+  &:global(.hidden) {
+    visibility: hidden;
+  }
+}

+ 75 - 0
src/components/caption/index.tsx

@@ -0,0 +1,75 @@
+import { useEffect, useState, useRef, useMemo } from "react"
+import { getElementScrollY, getCaptionScrollPX } from "@/common"
+import CaptionItem from "./caption-item"
+import { IUICaptionData } from "@/types"
+import { useSelector } from "react-redux"
+import { RootState } from "@/store"
+
+import styles from "./index.module.scss"
+
+interface ICaptionProps {
+  speed?: number
+  visible?: boolean
+}
+
+const Caption = (props: ICaptionProps) => {
+  const { visible } = props
+  const captionLanguages = useSelector((state: RootState) => state.global.captionLanguages)
+  const captionRef = useRef<HTMLDivElement>(null)
+  const subtitles = useSelector((state: RootState) => state.global.sttSubtitles)
+
+  const captionList: IUICaptionData[] = useMemo(() => {
+    const list: IUICaptionData[] = []
+    subtitles.forEach((el) => {
+      const captionData: IUICaptionData = {
+        userName: el.username,
+        translations: [],
+        content: "",
+      }
+      if (captionLanguages.includes("live")) {
+        captionData.content = el.text
+      }
+      el.translations?.forEach((tran) => {
+        const tranItem = { lang: tran.lang, text: tran.text }
+        if (captionLanguages.includes(tran.lang)) {
+          captionData.translations?.push(tranItem)
+        }
+      })
+      list.push(captionData)
+    })
+    return list
+  }, [captionLanguages, subtitles])
+
+  const animate = () => {
+    if (!captionRef.current) {
+      return
+    }
+    const curScrollY = getElementScrollY(captionRef.current)
+    if (curScrollY > 0) {
+      // TODO: use transformY instead of scrollTop
+      const curScrollTop = captionRef.current.scrollTop ?? 0
+      const val = getCaptionScrollPX(curScrollY)
+      captionRef.current.scrollTop = curScrollTop + val
+    }
+  }
+
+  useEffect(() => {
+    const id = setInterval(() => {
+      animate()
+    }, 35)
+
+    return () => {
+      clearInterval(id)
+    }
+  }, [captionList])
+
+  return (
+    <div className={`${styles.caption} ${!visible ? "hidden" : ""}`} ref={captionRef}>
+      {captionList.map((item, index) => (
+        <CaptionItem key={index} data={item}></CaptionItem>
+      ))}
+    </div>
+  )
+}
+
+export default Caption

+ 84 - 0
src/components/center-area/index.module.scss

@@ -0,0 +1,84 @@
+.centerArea {
+  position: relative;
+  width: 100%;
+  height: 100%;
+
+  .videoMute {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    display: inline-flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 4px;
+
+    .textWrapper {
+      display: flex;
+      padding: 2px 0px;
+      justify-content: center;
+      align-items: center;
+      gap: 8px;
+
+      .text {
+        color: var(--Grey-800, #344054);
+        font-size: 14px;
+        font-weight: 400;
+        line-height: 150%;
+        /* 21px */
+        letter-spacing: 0.449px;
+      }
+
+      .iconWrapper {
+        display: flex;
+        width: 20px;
+        height: 20px;
+        padding: 2px;
+        justify-content: center;
+        align-items: center;
+        gap: 10px;
+        border-radius: 50%;
+        background: var(--primary-500-base, #3d53f5);
+      }
+    }
+
+    .streamPlayerWrapper {
+      position: relative;
+      width: 0;
+      height: 0;
+    }
+  }
+
+  .videoUnMute {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    background-color: #000;
+
+    .userInfo {
+      position: absolute;
+      right: 12px;
+      top: 12px;
+      display: flex;
+      padding: 0 12px;
+      height: 40px;
+      align-items: center;
+      box-sizing: border-box;
+      background: rgba(0, 0, 0, 0.6);
+      border-radius: 14px;
+
+      .text {
+        margin-left: 8px;
+        margin-right: 8px;
+        color: var(--Grey-800, #344054);
+        font-family: "PingFang SC";
+        font-size: 14px;
+        font-style: normal;
+        font-weight: 400;
+        line-height: 150%;
+        color: white;
+      }
+    }
+  }
+}

+ 137 - 0
src/components/center-area/index.tsx

@@ -0,0 +1,137 @@
+import Avatar from "@/components/avatar"
+import { useEffect, useMemo, useRef, useState } from "react"
+import { IUserData } from "@/types"
+import { useSelector } from "react-redux"
+import { RootState } from "@/store"
+import { MicIcon, HostIcon } from "@/components/icons"
+import { LocalStreamPlayer, RemoteStreamPlayer } from "../stream-player"
+
+import styles from "./index.module.scss"
+
+export const RATIO = 1.777778
+
+interface ICenterAreaProps {
+  data: IUserData
+}
+
+interface IArea {
+  width: number
+  height: number
+}
+
+const CenterArea = (props: ICenterAreaProps) => {
+  const { data } = props
+  const userInfo = useSelector((state: RootState) => state.global.userInfo)
+  const localAudioMute = useSelector((state: RootState) => state.global.localAudioMute)
+  const localVideoMute = useSelector((state: RootState) => state.global.localVideoMute)
+  const centerAreaRef = useRef<HTMLDivElement>(null)
+  const [centerArea, setCenterArea] = useState<IArea>({ width: 0, height: 0 })
+
+  const videoMute = useMemo(() => {
+    if (data.isLocal) {
+      return localVideoMute
+    }
+    return !data.videoTrack
+  }, [localVideoMute, data])
+
+  const audioMute = useMemo(() => {
+    if (data.isLocal) {
+      return localAudioMute
+    }
+    return !data.audioTrack
+  }, [localAudioMute, data])
+
+  const userNameText = useMemo(() => {
+    return data.isLocal ? userInfo.userName + " (Me)" : data.userName
+  }, [data])
+
+  const resizeObserver = new ResizeObserver((entries) => {
+    const width = entries[0].contentRect.width
+    const height = entries[0].contentRect.height
+    setCenterArea({
+      width,
+      height,
+    })
+  })
+
+  useEffect(() => {
+    resizeObserver.observe(centerAreaRef.current as Element)
+    setCenterArea({
+      width: centerAreaRef.current?.offsetWidth || 0,
+      height: centerAreaRef.current?.offsetHeight || 0,
+    })
+    return () => {
+      resizeObserver.disconnect()
+    }
+  }, [centerAreaRef])
+
+  const videoWrapper: IArea = useMemo(() => {
+    const centerHeight = centerArea.height || 0
+    const centerWidth = centerArea.width || 0
+
+    let finalWidth = centerWidth
+    let finalHeight = finalWidth / RATIO
+
+    if (finalHeight > centerHeight) {
+      finalHeight = centerHeight
+      finalWidth = centerHeight * RATIO
+    }
+
+    return {
+      width: Math.floor(finalWidth),
+      height: Math.floor(finalHeight),
+    }
+  }, [centerArea, centerAreaRef])
+
+  return (
+    <div className={styles.centerArea} ref={centerAreaRef}>
+      {videoMute ? (
+        // only audio
+        <div className={styles.videoMute}>
+          <Avatar size="large" userName={data.userName} isHost={data.isHost}></Avatar>
+          <div className={styles.textWrapper}>
+            <span className={styles.text}>{userNameText}</span>
+            <span className={styles.iconWrapper}>
+              <MicIcon width={12} height={12} color="#fff" active={!audioMute}></MicIcon>
+            </span>
+          </div>
+          <div className={styles.streamPlayerWrapper}>
+            {data.isLocal ? (
+              <LocalStreamPlayer videoTrack={data.videoTrack}></LocalStreamPlayer>
+            ) : (
+              <RemoteStreamPlayer
+                audioTrack={data.audioTrack}
+                videoTrack={data.videoTrack}
+              ></RemoteStreamPlayer>
+            )}
+          </div>
+        </div>
+      ) : (
+        // has video
+        <div
+          className={styles.videoUnMute}
+          style={{
+            width: videoWrapper.width + "px",
+            height: videoWrapper.height + "px",
+          }}
+        >
+          {data.isLocal ? (
+            <LocalStreamPlayer videoTrack={data.videoTrack}></LocalStreamPlayer>
+          ) : (
+            <RemoteStreamPlayer
+              audioTrack={data.audioTrack}
+              videoTrack={data.videoTrack}
+            ></RemoteStreamPlayer>
+          )}
+          <div className={styles.userInfo}>
+            <MicIcon width={16} height={16} color="#fff" active={!audioMute}></MicIcon>
+            <span className={styles.text}>{userNameText}</span>
+            {data.isHost ? <HostIcon color="#FFAA08" width={16} height={16}></HostIcon> : null}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default CenterArea

+ 59 - 0
src/components/dialog/language-setting/index.module.scss

@@ -0,0 +1,59 @@
+.content {
+  padding: 20px 0;
+  border-top: 1px solid var(--Grey-300, #eaecf0);
+  border-bottom: 1px solid var(--Grey-300, #eaecf0);
+
+  .textTop {
+    margin-top: 16px;
+    color: var(--Grey-800, #344054);
+    font-size: 14px;
+    font-weight: 500;
+    line-height: 22px;
+  }
+
+  .textBottom {
+    color: var(--Grey-600, #667085);
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 22px;
+  }
+
+  .section {
+    margin-top: 16px;
+
+    .text {
+      color: var(--Grey-800, #344054);
+      font-size: 12px;
+      font-weight: 500;
+      line-height: 22px;
+    }
+
+    .selectWrapper {
+      display: inline-block;
+    }
+  }
+}
+
+.btnWrapper {
+  margin-top: 24px;
+  display: flex;
+  justify-content: flex-end;
+
+  .btn {
+    display: inline-block;
+    display: flex;
+    padding: 10px 18px;
+    justify-content: center;
+    align-items: center;
+    border-radius: 8px;
+    gap: 8px;
+    border: 1px solid var(--primary-500-base, #3d53f5);
+    background: var(--primary-500-base, #3d53f5);
+    color: var(---White, #fff);
+    font-size: 16px;
+    font-weight: 500;
+    line-height: 24px;
+    box-sizing: border-box;
+    cursor: pointer;
+  }
+}

+ 268 - 0
src/components/dialog/language-setting/index.tsx

@@ -0,0 +1,268 @@
+import { useDispatch, useSelector } from "react-redux"
+import { RootState } from "@/store"
+import { useEffect, useMemo, useRef, useState } from "react"
+import { Modal, Alert, Select, Space } from "antd"
+import { LANGUAGE_OPTIONS } from "@/common"
+import { LoadingOutlined } from "@ant-design/icons"
+import { ILanguageItem } from "@/manager"
+import { addMessage, setRecordLanguageSelect, setSubtitles } from "@/store/reducers/global"
+import { useTranslation } from "react-i18next"
+
+import styles from "./index.module.scss"
+
+interface ILanguageSettingDialogProps {
+  open?: boolean
+  onOk?: () => void
+  onCancel?: () => void
+}
+
+const SELECT_LIVE_LANGUAGE_PLACEHOLDER = "Select on-site language"
+const SELECT_TRANS_LANGUAGE_PLACEHOLDER = "Please select a language to translate into"
+const MAX_COUNT = 5
+let clickCount = 0
+const MAX_CLICK_COUNT = 5
+let time = 0
+
+const LanguageSettingDialog = (props: ILanguageSettingDialogProps) => {
+  const { open, onOk, onCancel } = props
+  const dispatch = useDispatch()
+  const { t } = useTranslation()
+  const sttData = useSelector((state: RootState) => state.global.sttData)
+  const languageSelect = useSelector((state: RootState) => state.global.languageSelect)
+  const { transcribe1, translate1List = [], transcribe2, translate2List = [] } = languageSelect
+  const [sourceLanguage1, setSourceLanguage1] = useState(transcribe1)
+  const [sourceLanguage1List, setSourceLanguage1List] = useState<string[]>(translate1List)
+  const [sourceLanguage2, setSourceLanguage2] = useState(transcribe2)
+  const [sourceLanguage2List, setSourceLanguage2List] = useState<string[]>(translate2List)
+  const [loading, setLoading] = useState(false)
+  const titleRef = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    if (transcribe1) {
+      setSourceLanguage1(transcribe1)
+    }
+    setSourceLanguage1List(translate1List)
+    if (transcribe2) {
+      setSourceLanguage2(transcribe2)
+    }
+    setSourceLanguage2List(translate2List)
+  }, [languageSelect])
+
+  const hasSttStarted = useMemo(() => {
+    return sttData.status == "start"
+  }, [sttData])
+
+  const languages = useMemo(() => {
+    const languages: ILanguageItem[] = []
+    if (sourceLanguage1) {
+      languages.push({
+        source: sourceLanguage1,
+        target: sourceLanguage1List,
+      })
+    }
+    if (sourceLanguage2) {
+      languages.push({
+        source: sourceLanguage2,
+        target: sourceLanguage2List,
+      })
+    }
+    return languages
+  }, [sourceLanguage1, sourceLanguage1List, sourceLanguage2, sourceLanguage2List])
+
+  const btnText = useMemo(() => {
+    if (!hasSttStarted) {
+      return t("setting.sttStart")
+    } else {
+      return t("setting.sttStop")
+    }
+  }, [hasSttStarted])
+
+  const checkSomeSourceLanguage = () => {
+    return sourceLanguage1 && sourceLanguage2 && sourceLanguage1 == sourceLanguage2
+  }
+
+  const onClickBtn = async () => {
+    if (loading) {
+      return
+    }
+    if (checkSomeSourceLanguage()) {
+      return dispatch(addMessage({ content: t("setting.sameLanguage"), type: "success" }))
+    }
+    setLoading(true)
+    try {
+      if (!hasSttStarted) {
+        await window.sttManager.startTranscription({
+          languages,
+        })
+      } else {
+        await window.sttManager.stopTranscription()
+      }
+    } catch (e: any) {
+      console.error(e)
+      dispatch(addMessage({ content: e.message, type: "error" }))
+    }
+    setLoading(false)
+    onOk?.()
+  }
+
+  const checkMaxTranslateList = (list1: string[] = [], list2: string[] = []) => {
+    const arr = [...new Set([...list1, ...list2])]
+    if (arr.length > 5) {
+      dispatch(addMessage({ content: t("setting.translationLanguageMax"), type: "error" }))
+      return false
+    }
+    return true
+  }
+
+  const onClickTitle = () => {
+    if (!time) {
+      time = new Date().getTime()
+    }
+    const now = new Date().getTime()
+    if (now - time < 1000) {
+      time = now
+      if (clickCount + 1 >= MAX_CLICK_COUNT) {
+        onMultipleClickTitle()
+        clickCount = 0
+        time = 0
+      } else {
+        clickCount++
+      }
+    } else {
+      time = now
+      clickCount = 1
+    }
+  }
+
+  const onMultipleClickTitle = () => {
+    const duration = 120 * 60 * 1000
+    window.sttManager.extendDuration({
+      duration,
+    })
+    dispatch(
+      addMessage({
+        content: t("message.extendExperience"),
+        type: "success",
+      }),
+    )
+  }
+
+  return (
+    <Modal
+      width={600}
+      title={
+        <div ref={titleRef} className="title" onClick={onClickTitle}>
+          {t("footer.langaugesSetting")}
+        </div>
+      }
+      open={open}
+      footer={null}
+      onOk={onOk}
+      onCancel={onCancel}
+    >
+      <div className={styles.content}>
+        <Alert message={t("setting.limitDuration")} showIcon type="warning" />
+        <div className={styles.textTop}>{t("setting.languagesSelect")}</div>
+        <div className={styles.textBottom}>{t("setting.tip")}</div>
+        <div className={styles.section}>
+          <Space>
+            <div className={styles.text} style={{ width: 160 }}>
+              {t("setting.liveLanguage")} 1
+            </div>
+            <div className={styles.text}>
+              {t("setting.liveLanguage")} 1 - {t("translationLanguage")}
+            </div>
+          </Space>
+          <div className={styles.selectWrapper}>
+            <Space>
+              <Select
+                value={sourceLanguage1}
+                onChange={(value) => {
+                  setSourceLanguage1(value)
+                  if (!value) {
+                    setSourceLanguage1List([])
+                  }
+                }}
+                disabled={hasSttStarted}
+                allowClear
+                placeholder={SELECT_LIVE_LANGUAGE_PLACEHOLDER}
+                style={{ width: 160 }}
+                options={LANGUAGE_OPTIONS}
+              />
+              <Select
+                value={sourceLanguage1List}
+                onChange={(value) => {
+                  if (checkMaxTranslateList(value, sourceLanguage2List)) {
+                    setSourceLanguage1List(value)
+                  }
+                }}
+                allowClear
+                disabled={hasSttStarted || !sourceLanguage1}
+                showSearch={false}
+                mode="multiple"
+                placeholder={SELECT_TRANS_LANGUAGE_PLACEHOLDER}
+                maxCount={MAX_COUNT}
+                style={{ width: 380 }}
+                maxTagTextLength={10}
+                options={LANGUAGE_OPTIONS}
+              />
+            </Space>
+          </div>
+        </div>
+        <div className={styles.section}>
+          <Space>
+            <div className={styles.text} style={{ width: 160 }}>
+              {t("setting.liveLanguage")} 2
+            </div>
+            <div className={styles.text}>
+              {t("setting.liveLanguage")} 2 - {t("translationLanguage")}
+            </div>
+          </Space>
+          <div className={styles.selectWrapper}>
+            <Space>
+              <Select
+                value={sourceLanguage2}
+                onChange={(value) => {
+                  setSourceLanguage2(value)
+                  if (!value) {
+                    setSourceLanguage2List([])
+                  }
+                }}
+                disabled={hasSttStarted}
+                allowClear
+                placeholder={SELECT_LIVE_LANGUAGE_PLACEHOLDER}
+                style={{ width: 160 }}
+                options={LANGUAGE_OPTIONS}
+              />
+              <Select
+                value={sourceLanguage2List}
+                onChange={(value) => {
+                  if (checkMaxTranslateList(sourceLanguage1List, value)) {
+                    setSourceLanguage2List(value)
+                  }
+                }}
+                disabled={hasSttStarted || !sourceLanguage2}
+                allowClear
+                showSearch={false}
+                mode="multiple"
+                placeholder={SELECT_TRANS_LANGUAGE_PLACEHOLDER}
+                maxCount={MAX_COUNT}
+                style={{ width: 380 }}
+                maxTagTextLength={10}
+                options={LANGUAGE_OPTIONS}
+              />
+            </Space>
+          </div>
+        </div>
+      </div>
+      <div className={styles.btnWrapper}>
+        <span className={styles.btn} onClick={onClickBtn}>
+          {btnText}
+          {loading ? <LoadingOutlined></LoadingOutlined> : null}
+        </span>
+      </div>
+    </Modal>
+  )
+}
+
+export default LanguageSettingDialog

+ 57 - 0
src/components/dialog/language-show/index.module.scss

@@ -0,0 +1,57 @@
+.content {
+  border-top: 1px solid var(--Grey-300, #eaecf0);
+
+  .textTop {
+    margin-top: 16px;
+    color: var(--Grey-800, #344054);
+    font-size: 14px;
+    font-weight: 500;
+    line-height: 22px;
+  }
+
+  .textBottom {
+    color: var(--Grey-600, #667085);
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 22px;
+  }
+
+  .section {
+    margin-top: 16px;
+
+    .text {
+      color: var(--Grey-800, #344054);
+      font-size: 12px;
+      font-weight: 500;
+      line-height: 22px;
+    }
+
+    .selectWrapper {
+      display: inline-block;
+    }
+  }
+}
+
+.btnWrapper {
+  margin-top: 24px;
+  display: flex;
+  justify-content: flex-end;
+
+  .btn {
+    display: inline-block;
+    display: flex;
+    padding: 10px 18px;
+    justify-content: center;
+    align-items: center;
+    border-radius: 8px;
+    gap: 8px;
+    border: 1px solid var(--primary-500-base, #3d53f5);
+    background: var(--primary-500-base, #3d53f5);
+    color: var(---White, #fff);
+    font-size: 16px;
+    font-weight: 500;
+    line-height: 24px;
+    box-sizing: border-box;
+    cursor: pointer;
+  }
+}

+ 180 - 0
src/components/dialog/language-show/index.tsx

@@ -0,0 +1,180 @@
+import { useDispatch, useSelector } from "react-redux"
+import { RootState } from "@/store"
+import { useEffect, useMemo, useState } from "react"
+import { Modal, Alert, Select, Space } from "antd"
+import { LANGUAGE_OPTIONS } from "@/common"
+import { setRecordLanguageSelect, addMessage } from "@/store/reducers/global"
+import { useTranslation } from "react-i18next"
+
+import styles from "./index.module.scss"
+
+interface ILanguageSettingDialogProps {
+  open?: boolean
+  onOk?: () => void
+  onCancel?: () => void
+}
+
+const SELECT_TRANS_LANGUAGE_PLACEHOLDER = "Please select a language to translate into"
+const MAX_COUNT = 2
+
+const LanguageShowDialog = (props: ILanguageSettingDialogProps) => {
+  const { open, onOk, onCancel } = props
+  const dispatch = useDispatch()
+  const { t } = useTranslation()
+  const languageSelect = useSelector((state: RootState) => state.global.languageSelect)
+  const recordLanguageSelect = useSelector((state: RootState) => state.global.recordLanguageSelect)
+  const {
+    transcribe1,
+    translate1List: captionTranslate1List = [],
+    transcribe2,
+    translate2List: captionTranslate2List = [],
+  } = languageSelect
+  const { translate1List: recordTranslate1List = [], translate2List: recordTranslate2List = [] } =
+    recordLanguageSelect
+  const [translateLanguage1List, setTranslateLanguage1List] =
+    useState<string[]>(recordTranslate1List)
+  const [translateLanguage2List, setTranslateLanguage2List] =
+    useState<string[]>(recordTranslate2List)
+
+  useEffect(() => {
+    setTranslateLanguage1List(recordTranslate1List)
+    setTranslateLanguage2List(recordTranslate2List)
+  }, [recordLanguageSelect])
+
+  const translateLanguage1Options = useMemo(() => {
+    const options: any[] = []
+    captionTranslate1List.forEach((item) => {
+      const target = LANGUAGE_OPTIONS.find((el) => el.value === item)
+      if (target) {
+        options.push({
+          value: target?.value,
+          label: target?.label,
+        })
+      }
+    })
+    return options
+  }, [captionTranslate1List])
+
+  const translateLanguage2Options = useMemo(() => {
+    const options: any[] = []
+    captionTranslate2List.forEach((item) => {
+      const target = LANGUAGE_OPTIONS.find((el) => el.value === item)
+      if (target) {
+        options.push({
+          value: target.value,
+          label: target.label,
+        })
+      }
+    })
+
+    return options
+  }, [captionTranslate2List])
+
+  const onClickBtn = async () => {
+    dispatch(
+      setRecordLanguageSelect({
+        translate1List: translateLanguage1List,
+        translate2List: translateLanguage2List,
+      }),
+    )
+    dispatch(
+      addMessage({
+        content: t("setting.saveSuccess"),
+        type: "success",
+      }),
+    )
+    onOk?.()
+  }
+
+  return (
+    <Modal
+      width={600}
+      title={t("dialog.languageShow")}
+      open={open}
+      footer={null}
+      onOk={onOk}
+      onCancel={onCancel}
+    >
+      <div className={styles.content}>
+        <div className={styles.section}>
+          <Space>
+            <div className={styles.text} style={{ width: 160 }}>
+              {t("setting.liveLanguage")} 1
+            </div>
+            <div className={styles.text}>
+              {t("setting.liveLanguage")} 1 - {t("translationLanguage")}
+            </div>
+          </Space>
+          <div className={styles.selectWrapper}>
+            <Space>
+              <Select
+                value={transcribe1}
+                disabled={true}
+                style={{ width: 160 }}
+                options={LANGUAGE_OPTIONS}
+              />
+              <Select
+                value={translateLanguage1List}
+                onChange={(value) => {
+                  setTranslateLanguage1List(value)
+                }}
+                allowClear
+                disabled={!transcribe1}
+                showSearch={false}
+                mode="multiple"
+                placeholder={SELECT_TRANS_LANGUAGE_PLACEHOLDER}
+                maxCount={MAX_COUNT}
+                style={{ width: 380 }}
+                maxTagTextLength={10}
+                options={translateLanguage1Options}
+              />
+            </Space>
+          </div>
+        </div>
+        <div className={styles.section}>
+          <Space>
+            <div className={styles.text} style={{ width: 160 }}>
+              {t("setting.liveLanguage")} 2
+            </div>
+            <div className={styles.text}>
+              {t("setting.liveLanguage")} 2 - {t("translationLanguage")}
+            </div>
+          </Space>
+          <div className={styles.selectWrapper}>
+            <Space>
+              <Select
+                value={transcribe2}
+                disabled={true}
+                allowClear
+                style={{ width: 160 }}
+                options={LANGUAGE_OPTIONS}
+              />
+              <Select
+                value={translateLanguage2List}
+                onChange={(value) => {
+                  setTranslateLanguage2List(value)
+                }}
+                disabled={!transcribe2}
+                allowClear
+                showSearch={false}
+                mode="multiple"
+                placeholder={SELECT_TRANS_LANGUAGE_PLACEHOLDER}
+                maxCount={MAX_COUNT}
+                style={{ width: 380 }}
+                maxTagTextLength={10}
+                options={translateLanguage2Options}
+              />
+            </Space>
+          </div>
+        </div>
+      </div>
+      <div className={styles.btnWrapper}>
+        <span className={styles.btn} onClick={onClickBtn}>
+          {t("confirm")}
+        </span>
+      </div>
+    </Modal>
+  )
+}
+
+export default LanguageShowDialog

+ 57 - 0
src/components/dialog/language-storage/index.module.scss

@@ -0,0 +1,57 @@
+.content {
+  border-top: 1px solid var(--Grey-300, #eaecf0);
+
+  .textTop {
+    margin-top: 16px;
+    color: var(--Grey-800, #344054);
+    font-size: 14px;
+    font-weight: 500;
+    line-height: 22px;
+  }
+
+  .textBottom {
+    color: var(--Grey-600, #667085);
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 22px;
+  }
+
+  .section {
+    margin-top: 16px;
+
+    .text {
+      color: var(--Grey-800, #344054);
+      font-size: 12px;
+      font-weight: 500;
+      line-height: 22px;
+    }
+
+    .selectWrapper {
+      display: inline-block;
+    }
+  }
+}
+
+.btnWrapper {
+  margin-top: 24px;
+  display: flex;
+  justify-content: flex-end;
+
+  .btn {
+    display: inline-block;
+    display: flex;
+    padding: 10px 18px;
+    justify-content: center;
+    align-items: center;
+    border-radius: 8px;
+    gap: 8px;
+    border: 1px solid var(--primary-500-base, #3d53f5);
+    background: var(--primary-500-base, #3d53f5);
+    color: var(---White, #fff);
+    font-size: 16px;
+    font-weight: 500;
+    line-height: 24px;
+    box-sizing: border-box;
+    cursor: pointer;
+  }
+}

+ 144 - 0
src/components/dialog/language-storage/index.tsx

@@ -0,0 +1,144 @@
+import { useDispatch, useSelector } from "react-redux"
+import { RootState } from "@/store"
+import { Modal, Alert, Select, Space } from "antd"
+import { ITextItem, LangDataType } from "@/types"
+import { LANGUAGE_OPTIONS, downloadText, genContentText } from "@/common"
+import { addMessage } from "@/store/reducers/global"
+import { useTranslation } from "react-i18next"
+
+import styles from "./index.module.scss"
+import { useMemo, useState } from "react"
+
+interface ILanguageSettingDialogProps {
+  open?: boolean
+  onOk?: () => void
+  onCancel?: () => void
+}
+
+export const genTranslateContentText = (lang: string, type: LangDataType, list: ITextItem[]) => {
+  let res = ""
+  if (type == "transcribe") {
+    list.forEach((item) => {
+      if (item.lang === lang) {
+        res += `${item.username}: ${item.text}\n`
+      }
+    })
+  } else {
+    list.forEach((item) => {
+      item.translations?.forEach((v) => {
+        if (v.lang === lang) {
+          res += `${item.username}: ${v.text}\n`
+        }
+      })
+    })
+  }
+  return res
+}
+
+const LanguageStorageDialog = (props: ILanguageSettingDialogProps) => {
+  const dispatch = useDispatch()
+  const { open, onOk, onCancel } = props
+  const { t } = useTranslation()
+  const options = useSelector((state: RootState) => state.global.options)
+  const languageSelect = useSelector((state: RootState) => state.global.languageSelect)
+  const sttSubtitles = useSelector((state: RootState) => state.global.sttSubtitles)
+  const recordLanguageSelect = useSelector((state: RootState) => state.global.recordLanguageSelect)
+  const { transcribe1, transcribe2 } = languageSelect
+  const { translate1List = [], translate2List = [] } = recordLanguageSelect
+  const { channel } = options
+  const [language, setLanguage] = useState<string>()
+
+  const languageOptions = useMemo(() => {
+    const res: any[] = []
+
+    if (transcribe1) {
+      const target = LANGUAGE_OPTIONS.find((item) => item.value === transcribe1)
+      res.push({
+        value: target?.value,
+        label: "live: " + target?.label,
+      })
+    }
+
+    if (transcribe2) {
+      const target = LANGUAGE_OPTIONS.find((item) => item.value === transcribe2)
+      res.push({
+        value: target?.value,
+        label: "live: " + target?.label,
+      })
+    }
+
+    translate1List.forEach((lang) => {
+      const target = LANGUAGE_OPTIONS.find((item) => item.value === lang)
+      res.push({
+        value: lang,
+        label: "translate: " + target?.label,
+      })
+    })
+
+    translate2List.forEach((lang) => {
+      const target = LANGUAGE_OPTIONS.find((item) => item.value === lang)
+      if (res.find((item) => item.value === lang)) return
+      res.push({
+        value: lang,
+        label: "translate: " + target?.label,
+      })
+    })
+
+    return res
+  }, [transcribe1, transcribe2, translate1List, translate2List])
+
+  const curType: LangDataType = useMemo(() => {
+    if (language == transcribe1) {
+      return "transcribe"
+    }
+    if (language == transcribe2) {
+      return "transcribe"
+    }
+
+    return "translate"
+  }, [transcribe1, transcribe2, language])
+
+  const onClickBtn = async () => {
+    onOk?.()
+    if (language) {
+      const name = `${channel}_${language}`
+      const content = genTranslateContentText(language, curType, sttSubtitles)
+      downloadText(`${name}.txt`, content)
+      dispatch(addMessage({ type: "success", content: t("storage.success") }))
+    }
+  }
+
+  const onChange = (value: string) => {
+    setLanguage(value)
+  }
+
+  return (
+    <Modal
+      width={600}
+      title={t("dialog.languageExport")}
+      open={open}
+      footer={null}
+      onOk={onOk}
+      onCancel={onCancel}
+    >
+      <section className={styles.content}>
+        <div className={styles.textTop}>{t("dialog.languageExportTip")}</div>
+        <div className={styles.section}>
+          <Select
+            value={language}
+            onChange={onChange}
+            style={{ width: 300 }}
+            options={languageOptions}
+          />
+        </div>
+      </section>
+      <div className={styles.btnWrapper}>
+        <span className={styles.btn} onClick={onClickBtn}>
+          {t("confirm")}
+        </span>
+      </div>
+    </Modal>
+  )
+}
+
+export default LanguageStorageDialog

+ 41 - 0
src/components/extend-message/index.module.scss

@@ -0,0 +1,41 @@
+.extendMessage {
+  position: fixed;
+  bottom: 110px;
+  left: 50%;
+  transform: translateX(-50%);
+  display: inline-flex;
+  padding: 8px 12px;
+  align-items: center;
+  gap: 24px;
+  border-radius: 8px;
+  border: 1px solid var(--Grey-300, #eaecf0);
+  background: #fff;
+  box-shadow:
+    0px 4px 6px -2px rgba(16, 24, 40, 0.03),
+    0px 12px 16px -4px rgba(16, 24, 40, 0.08);
+  cursor: pointer;
+
+  .text {
+    color: var(---6_colorTextPrimary, #667085);
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 22px;
+    /* 157.143% */
+  }
+
+  .btn {
+    display: flex;
+    padding: 3px 12px;
+    justify-content: center;
+    align-items: center;
+    gap: 4px;
+    border-radius: 4px;
+    background: var(--primary-500-base, #3d53f5);
+    color: var(---White, #fff);
+    font-family: "Helvetica Neue";
+    font-size: 12px;
+    font-style: normal;
+    font-weight: 400;
+    line-height: 18px;
+  }
+}

+ 35 - 0
src/components/extend-message/index.tsx

@@ -0,0 +1,35 @@
+import React, { useState } from "react"
+import { CloseOutlined } from "@ant-design/icons"
+import { useDispatch } from "react-redux"
+import { useTranslation } from "react-i18next"
+
+import styles from "./index.module.scss"
+
+interface IExtendMessageProps {
+  open?: boolean
+  onClose?: () => void
+}
+
+const ExtendMessage = (props: IExtendMessageProps) => {
+  const { open = false, onClose } = props
+  const dispatch = useDispatch()
+  const { t } = useTranslation()
+
+  const onClickExtend = () => {
+    // window.sttManager.reStartTranscription()
+    // window.rtmManager.updateSttStatus("start")
+    onClose?.()
+  }
+
+  return open ? (
+    <div className={styles.extendMessage}>
+      <span className={styles.text}>{t("conversation.extendExperienceFreeText")}</span>
+      <span className={styles.btn} onClick={onClickExtend}>
+        {t("conversation.extendExperience")}
+      </span>
+      <CloseOutlined onClick={() => onClose?.()}></CloseOutlined>
+    </div>
+  ) : null
+}
+
+export default ExtendMessage

+ 28 - 0
src/components/footer/caption-popover/index.module.scss

@@ -0,0 +1,28 @@
+.content {
+  position: relative;
+  padding: 4px 4px;
+
+  .item {
+    height: 30px;
+    width: 180px;
+    padding: 4px 8px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex: 1 0 0;
+    border-radius: 4px;
+    cursor: pointer;
+
+    .text {
+      flex: 1 1 auto;
+      color: var(--Grey-800, #344054);
+      font-size: 14px;
+      font-weight: 400;
+      line-height: 22px; /* 157.143% */
+    }
+
+    &:global(.active) {
+      background-color: var(--Grey-100, #f9fafb);
+    }
+  }
+}

+ 104 - 0
src/components/footer/caption-popover/index.tsx

@@ -0,0 +1,104 @@
+import { Popover } from "antd"
+import { CheckOutlined } from "@ant-design/icons"
+import { LANGUAGE_LIST } from "@/common"
+import { RootState } from "@/store"
+import { setCaptionLanguages } from "@/store/reducers/global"
+import { useSelector, useDispatch } from "react-redux"
+import { DialogLanguageType } from "@/types"
+
+import styles from "./index.module.scss"
+import { useMemo } from "react"
+import { useTranslation } from "react-i18next"
+
+interface ICaptionPopoverProps {
+  children?: React.ReactNode
+}
+
+interface CaptionPopoverItem {
+  text: string
+  active: boolean
+  stt: string
+  type: DialogLanguageType
+}
+
+const CaptionPopover = (props: ICaptionPopoverProps) => {
+  const { children } = props
+  const dispatch = useDispatch()
+  const { t } = useTranslation()
+  const captionLanguages = useSelector((state: RootState) => state.global.captionLanguages)
+  const languageSelect = useSelector((state: RootState) => state.global.languageSelect)
+
+  const captionItems = useMemo(() => {
+    const items: CaptionPopoverItem[] = []
+    items.push({
+      text: t("liveLanguage"),
+      stt: "live",
+      type: "live",
+      active: captionLanguages.includes("live"),
+    })
+    const { translate1List = [], translate2List = [] } = languageSelect
+    const translateArr = [...new Set([...translate1List, ...translate2List])]
+    for (let i = 0; i < translateArr.length; i++) {
+      const target = LANGUAGE_LIST.find((item) => item.code == translateArr[i])
+      if (target) {
+        items.push({
+          text: target?.label || "",
+          stt: translateArr[i],
+          type: "translate",
+          active: captionLanguages.includes(translateArr[i]),
+        })
+      }
+    }
+
+    return items
+  }, [captionLanguages, languageSelect])
+
+  const onSelect = (item: CaptionPopoverItem) => {
+    const languages = [...captionLanguages]
+    const index = languages.indexOf(item.stt)
+    if (index > -1) {
+      languages.splice(index, 1)
+    } else {
+      languages.push(item.stt)
+    }
+    const translateArr = languages.filter((item) => item !== "live")
+    if (translateArr.length >= 2) {
+      const index = languages.findIndex((item) => item !== "live")
+      languages.splice(index, 1)
+    }
+    dispatch(setCaptionLanguages(languages))
+  }
+
+  return (
+    <Popover
+      overlayInnerStyle={{ padding: 0 }}
+      content={
+        <div className={styles.content}>
+          {captionItems.map((item, index) => {
+            return (
+              <div
+                key={index}
+                className={`${styles.item} ${item.active ? "active" : ""}`}
+                onClick={() => onSelect(item)}
+              >
+                <span className={styles.text}>{item.text}</span>
+                {item.active ? (
+                  <CheckOutlined
+                    style={{
+                      color: "#3D53F5",
+                    }}
+                  ></CheckOutlined>
+                ) : null}
+              </div>
+            )
+          })}
+        </div>
+      }
+      trigger="click"
+    >
+      {children}
+    </Popover>
+  )
+}
+
+export default CaptionPopover

+ 95 - 0
src/components/footer/index.module.scss

@@ -0,0 +1,95 @@
+.footer {
+  position: relative;
+  display: flex;
+  width: 100%;
+  height: 80px;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  flex-shrink: 0;
+  border-top: 1px solid #eaecf0;
+  background: #fff;
+  box-sizing: border-box;
+
+  .content {
+    display: flex;
+    align-items: center;
+    height: 50px;
+
+    .item {
+      padding: 8px;
+      height: 50px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      cursor: pointer;
+      border-radius: 4px;
+
+      &:hover:not(.left) {
+        background: var(--Grey-200, #f2f4f7);
+      }
+
+      &:global(.disabled) {
+        color: #98a2b3;
+        cursor: not-allowed;
+
+        .text {
+          color: #98a2b3;
+        }
+
+        &:hover {
+          background: white;
+        }
+      }
+
+      .text {
+        margin-top: 4px;
+        color: #667085;
+        text-align: center;
+        font-size: 14px;
+        font-weight: 400;
+        line-height: 21px;
+      }
+    }
+
+    .item + .item {
+      margin-left: 24px;
+    }
+
+    .arrowWrapper {
+      height: 66px;
+      width: 24px;
+      margin-right: 24px;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      border-radius: 4px;
+      box-sizing: border-box;
+      cursor: pointer;
+
+      &:hover {
+        background: #f2f4f7;
+      }
+    }
+  }
+
+  .end {
+    display: flex;
+    padding: 8px 14px;
+    justify-content: center;
+    align-items: center;
+    gap: 8px;
+    position: absolute;
+    right: 40px;
+    top: 20px;
+    border-radius: 6px;
+    border: 1px solid var(--error-500-base, #df1642);
+    background: var(---white, #fff);
+    cursor: pointer;
+    color: var(--error-500-base, #df1642);
+    font-size: 14px;
+    font-weight: 500;
+    line-height: 20px;
+    /* 142.857% */
+  }
+}

+ 193 - 0
src/components/footer/index.tsx

@@ -0,0 +1,193 @@
+import {
+  MicIcon,
+  CamIcon,
+  MemberIcon,
+  CaptionIcon,
+  TranscriptionIcon,
+  SettingIcon,
+  AiIcon,
+  ArrowUpIcon,
+} from "../icons"
+import { showAIModule } from "@/common"
+import { useSelector, useDispatch } from "react-redux"
+import {
+  setUserInfo,
+  setMemberListShow,
+  setDialogRecordShow,
+  setCaptionShow,
+  setAIShow,
+  removeMenuItem,
+  addMenuItem,
+  setLocalAudioMute,
+  setLocalVideoMute,
+  addMessage,
+  setTipSTTEnable,
+} from "@/store/reducers/global"
+import LanguageSettingDialog from "../dialog/language-setting"
+import CaptionPopover from "./caption-popover"
+import { Popover } from "antd"
+import { RootState } from "@/store"
+import { useEffect, useMemo, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { useNavigate, useLocation } from "react-router-dom"
+
+import styles from "./index.module.scss"
+
+interface IFooterProps {
+  style?: React.CSSProperties
+}
+
+const Footer = (props: IFooterProps) => {
+  const { style } = props
+  const nav = useNavigate()
+  const dispatch = useDispatch()
+  const { t } = useTranslation()
+  const location = useLocation()
+  const localAudioMute = useSelector((state: RootState) => state.global.localAudioMute)
+  const localVideoMute = useSelector((state: RootState) => state.global.localVideoMute)
+  const memberListShow = useSelector((state: RootState) => state.global.memberListShow)
+  const dialogRecordShow = useSelector((state: RootState) => state.global.dialogRecordShow)
+  const captionShow = useSelector((state: RootState) => state.global.captionShow)
+  const tipSTTEnable = useSelector((state: RootState) => state.global.tipSTTEnable)
+  const aiShow = useSelector((state: RootState) => state.global.aiShow)
+  const sttData = useSelector((state: RootState) => state.global.sttData)
+  const [showLanguageSetting, setShowLanguageSetting] = useState(false)
+
+  useEffect(() => {
+    if (tipSTTEnable) {
+      setTimeout(() => {
+        dispatch(setTipSTTEnable(false))
+      }, 4000)
+    }
+  }, [tipSTTEnable])
+
+  const hasSttStarted = useMemo(() => {
+    return sttData.status === "start"
+  }, [sttData])
+
+  const MicText = useMemo(() => {
+    return localAudioMute ? t("footer.unMuteAudio") : t("footer.muteAudio")
+  }, [localAudioMute])
+
+  const CameraText = useMemo(() => {
+    return localVideoMute ? t("footer.unMuteVideo") : t("footer.muteVideo")
+  }, [localVideoMute])
+
+  const captionText = useMemo(() => {
+    return captionShow ? t("footer.stopCC") : t("footer.startCC")
+  }, [captionShow])
+
+  const onClickMic = () => {
+    dispatch(setLocalAudioMute(!localAudioMute))
+  }
+
+  const onClickCam = () => {
+    dispatch(setLocalVideoMute(!localVideoMute))
+  }
+
+  const onClickMember = () => {
+    dispatch(setMemberListShow(!memberListShow))
+  }
+
+  const onClickDialogRecord = () => {
+    dispatch(setDialogRecordShow(!dialogRecordShow))
+    if (dialogRecordShow) {
+      dispatch(removeMenuItem("DialogRecord"))
+    } else {
+      dispatch(addMenuItem("DialogRecord"))
+    }
+  }
+
+  const onClickCaption = () => {
+    if (sttData.status !== "start") {
+      return dispatch(setTipSTTEnable(true))
+    }
+    dispatch(setCaptionShow(!captionShow))
+  }
+
+  const onClickAiShow = () => {
+    dispatch(setAIShow(!aiShow))
+    if (aiShow) {
+      dispatch(removeMenuItem("AI"))
+    } else {
+      dispatch(addMenuItem("AI"))
+    }
+  }
+
+  const toggleLanguageSettingDialog = () => {
+    setShowLanguageSetting(!showLanguageSetting)
+  }
+
+  const onClickEnd = () => {
+    if (location.search) {
+      nav(`/?${location.search.slice(1)}`)
+    } else {
+      nav("/")
+    }
+    dispatch(addMessage({ content: "end meeting success!", type: "success" }))
+  }
+
+  return (
+    <footer className={styles.footer} style={style}>
+      <section className={styles.content}>
+        {/* audio */}
+        <span className={styles.item} onClick={onClickMic}>
+          <MicIcon active={!localAudioMute}></MicIcon>
+          <span className={styles.text}>{MicText}</span>
+        </span>
+        {/* video */}
+        <span className={styles.item} onClick={onClickCam}>
+          <CamIcon active={!localVideoMute}></CamIcon>
+          <span className={styles.text}>{CameraText}</span>
+        </span>
+        {/* member */}
+        <span className={styles.item} onClick={onClickMember}>
+          <MemberIcon active={memberListShow}></MemberIcon>
+          <span className={styles.text}>{t("footer.participantsList")}</span>
+        </span>
+        {/* caption */}
+        <span
+          className={`${styles.item} ${!hasSttStarted ? "disabled" : ""}`}
+          onClick={onClickCaption}
+        >
+          <CaptionIcon disabled={!hasSttStarted} active={captionShow}></CaptionIcon>
+          <span className={styles.text}>{captionText}</span>
+        </span>
+        <CaptionPopover>
+          <span className={styles.arrowWrapper}>
+            <ArrowUpIcon width={16} height={16}></ArrowUpIcon>
+          </span>
+        </CaptionPopover>
+        {/* dialog */}
+        <span className={`${styles.item}`} onClick={onClickDialogRecord}>
+          <TranscriptionIcon active={dialogRecordShow}></TranscriptionIcon>
+          <span className={styles.text}>{t("footer.conversationHistory")}</span>
+        </span>
+        {/* language */}
+        <Popover placement="top" content={t("footer.tipEnableSTTFirst")} open={tipSTTEnable}>
+          <span className={`${styles.item}`} onClick={toggleLanguageSettingDialog}>
+            <SettingIcon></SettingIcon>
+            <span className={`${styles.text}`}>{t("footer.langaugesSetting")}</span>
+          </span>
+        </Popover>
+        {/* ai */}
+        {showAIModule() ? (
+          <span className={styles.item} onClick={onClickAiShow}>
+            <AiIcon active={aiShow}></AiIcon>
+            <span className={styles.text}>{t("footer.aIAssistant")}</span>
+          </span>
+        ) : null}
+      </section>
+      <span className={styles.end} onClick={onClickEnd}>
+        {t("closeConversation")}
+      </span>
+      <LanguageSettingDialog
+        open={showLanguageSetting}
+        onOk={() => setShowLanguageSetting(false)}
+        onCancel={() => setShowLanguageSetting(false)}
+      ></LanguageSettingDialog>
+    </footer>
+  )
+}
+
+export default Footer

+ 39 - 0
src/components/header/index.module.scss

@@ -0,0 +1,39 @@
+.header {
+  display: flex;
+  padding: 0 24px;
+  width: 100%;
+  height: 48px;
+  padding: 24px;
+  align-items: center;
+  flex-shrink: 0;
+  background: #fff;
+  box-shadow:
+    0px 4px 6px -2px rgba(8, 15, 52, 0.03),
+    0px 12px 16px -4px rgba(8, 15, 52, 0.06);
+  box-sizing: border-box;
+  z-index: 1;
+
+  .channelName {
+    margin-left: 6px;
+    color: #667085;
+    font-size: 14px;
+    font-weight: 400;
+  }
+
+  .transcription {
+    flex: 1 1 auto;
+    display: flex;
+    align-items: center;
+    margin-left: 6px;
+    font-size: 0;
+
+    .text {
+      margin-left: 4px;
+      color: #667085;
+      text-align: right;
+      font-size: 14px;
+      font-weight: 400;
+      line-height: 21px;
+    }
+  }
+}

+ 61 - 0
src/components/header/index.tsx

@@ -0,0 +1,61 @@
+import { TranscriptionIcon } from "../icons"
+import { RootState } from "@/store"
+import Time from "./time"
+import NetWork from "./network"
+import { useSelector } from "react-redux"
+import { useTranslation } from "react-i18next"
+
+import styles from "./index.module.scss"
+
+interface IHeaderProps {
+  style?: React.CSSProperties
+}
+
+const Header = (props: IHeaderProps) => {
+  const { style } = props
+  const sttData = useSelector((state: RootState) => state.global.sttData)
+  const options = useSelector((state: RootState) => state.global.options)
+  const { channel } = options
+  const { t } = useTranslation()
+
+  const onClickChannel = async () => {
+    // test stt query api
+    // const res = await window.sttManager.queryTranscription()
+    // console.log("[test]", res)
+    // ...
+    // test stt update api
+    // const res = await window.sttManager.updateTranscription({
+    //   data: {
+    //     languages: ["zh-CN"],
+    //     rtcConfig: {
+    //       subscribeAudioUids: ["111"],
+    //     },
+    //     translateConfig: {
+    //       enable: false,
+    //     },
+    //   },
+    //   updateMaskList: ["languages", "rtcConfig.subscribeAudioUids", "translateConfig.enable"],
+    // })
+    // console.log("[test]", res)
+  }
+
+  return (
+    <header className={styles.header} style={style}>
+      <NetWork></NetWork>
+      <span className={styles.channelName} onClick={onClickChannel}>
+        {channel}
+      </span>
+      <span className={styles.transcription}>
+        {sttData.status == "start" ? (
+          <>
+            <TranscriptionIcon></TranscriptionIcon>
+            <span className={styles.text}>{t("transcribing")}</span>
+          </>
+        ) : null}
+      </span>
+      <Time></Time>
+    </header>
+  )
+}
+
+export default Header

+ 0 - 0
src/components/header/network/index.module.scss


+ 27 - 0
src/components/header/network/index.tsx

@@ -0,0 +1,27 @@
+import { NetworkQuality } from "agora-rtc-sdk-ng"
+import { useEffect, useState } from "react"
+import { NetworkIcon } from "@/components/icons"
+
+const NetWork = () => {
+  const [networkQuality, setNetworkQuality] = useState<NetworkQuality>()
+
+  useEffect(() => {
+    window.rtcManager.on("networkQuality", onNetworkQuality)
+
+    return () => {
+      window.rtcManager.off("networkQuality", onNetworkQuality)
+    }
+  }, [])
+
+  const onNetworkQuality = (quality: NetworkQuality) => {
+    setNetworkQuality(quality)
+  }
+
+  return (
+    <span>
+      <NetworkIcon level={networkQuality?.uplinkNetworkQuality}></NetworkIcon>
+    </span>
+  )
+}
+
+export default NetWork

+ 16 - 0
src/components/header/time/index.module.scss

@@ -0,0 +1,16 @@
+.time {
+  flex: 0 0 auto;
+  display: flex;
+  align-items: center;
+  font-size: 0;
+
+  .text {
+    display: inline-block;
+    min-width: 40px;
+    margin-left: 4px;
+    color: #667085;
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 21px;
+  }
+}

+ 34 - 0
src/components/header/time/index.tsx

@@ -0,0 +1,34 @@
+import { useEffect, useState } from "react"
+import { TimeIcon } from "../../icons"
+import { formatTime } from "@/common"
+import styles from "./index.module.scss"
+
+const Time = () => {
+  const [duration, setDuration] = useState(0)
+
+  useEffect(() => {
+    const intervalId = setInterval(() => {
+      getRTCStats()
+    }, 1000)
+    return () => {
+      if (intervalId) {
+        clearInterval(intervalId)
+      }
+    }
+  }, [])
+
+  const getRTCStats = () => {
+    const status = window.rtcManager.client.getRTCStats()
+    const duration = status?.Duration ?? 0
+    setDuration(duration)
+  }
+
+  return (
+    <span className={styles.time}>
+      <TimeIcon></TimeIcon>
+      <span className={styles.text}>{formatTime(duration)}</span>
+    </span>
+  )
+}
+
+export default Time

+ 16 - 0
src/components/icons/ai/index.tsx

@@ -0,0 +1,16 @@
+import aiSvg from "@/assets/ai.svg?react"
+import { IconProps } from "../types"
+
+interface IAiIconProps extends IconProps {
+  active?: boolean
+}
+
+export const AiIcon = (props: IAiIconProps) => {
+  const { active, ...rest } = props
+  const color = active ? "#3D53F5" : "#667085"
+
+  return aiSvg({
+    color,
+    ...rest,
+  })
+}

+ 6 - 0
src/components/icons/arrow-up/index.tsx

@@ -0,0 +1,6 @@
+import arrowUpSvg from "@/assets/arrow_up.svg?react"
+import { IconProps } from "../types"
+
+export const ArrowUpIcon = (props: IconProps) => {
+  return arrowUpSvg(props)
+}

+ 17 - 0
src/components/icons/cam/index.tsx

@@ -0,0 +1,17 @@
+import camMuteSvg from "@/assets/cam_mute.svg?react"
+import camUnMuteSvg from "@/assets/cam_unmute.svg?react"
+import { IconProps } from "../types"
+
+interface ICamIconProps extends IconProps {
+  active?: boolean
+}
+
+export const CamIcon = (props: ICamIconProps) => {
+  const { active, ...rest } = props
+
+  if (active) {
+    return camUnMuteSvg(rest)
+  } else {
+    return camMuteSvg(rest)
+  }
+}

+ 23 - 0
src/components/icons/caption/index.tsx

@@ -0,0 +1,23 @@
+import captionSvg from "@/assets/caption.svg?react"
+import { IconProps } from "../types"
+
+interface ICaptionIconProps extends IconProps {
+  active?: boolean
+  disabled?: boolean
+}
+
+export const CaptionIcon = (props: ICaptionIconProps) => {
+  const { active, disabled, ...rest } = props
+  let color = "#667085"
+  if (active) {
+    color = "#3D53F5"
+  }
+  if (disabled) {
+    color = "#98A2B3"
+  }
+
+  return captionSvg({
+    color,
+    ...rest,
+  })
+}

+ 6 - 0
src/components/icons/host/index.tsx

@@ -0,0 +1,6 @@
+import hostSvg from "@/assets/host.svg?react"
+import { IconProps } from "../types"
+
+export const HostIcon = (props: IconProps) => {
+  return hostSvg(props)
+}

+ 12 - 0
src/components/icons/index.tsx

@@ -0,0 +1,12 @@
+export * from "./transcription"
+export * from "./time"
+export * from "./mic"
+export * from "./cam"
+export * from "./member"
+export * from "./caption"
+export * from "./setting"
+export * from "./ai"
+export * from "./arrow-up"
+export * from "./host"
+export * from "./network"
+export * from "./upload"

+ 16 - 0
src/components/icons/member/index.tsx

@@ -0,0 +1,16 @@
+import MemberSvg from "@/assets/member.svg?react"
+import { IconProps } from "../types"
+
+interface IMemberIconProps extends IconProps {
+  active?: boolean
+}
+
+export const MemberIcon = (props: IMemberIconProps) => {
+  const { active, ...rest } = props
+  const color = active ? "#3D53F5" : "#667085"
+
+  return MemberSvg({
+    color,
+    ...rest,
+  })
+}

+ 23 - 0
src/components/icons/mic/index.tsx

@@ -0,0 +1,23 @@
+import { IconProps } from "../types"
+import micMuteSvg from "@/assets/mic_mute.svg?react"
+import micUnMuteSvg from "@/assets/mic_unmute.svg?react"
+
+interface IMicIconProps extends IconProps {
+  active?: boolean
+}
+
+export const MicIcon = (props: IMicIconProps) => {
+  const { active, color, ...rest } = props
+
+  if (active) {
+    return micUnMuteSvg({
+      color: color || "#3D53F5",
+      ...rest,
+    })
+  } else {
+    return micMuteSvg({
+      color: color || "#667085",
+      ...rest,
+    })
+  }
+}

+ 35 - 0
src/components/icons/network/index.tsx

@@ -0,0 +1,35 @@
+// https://doc.shengwang.cn/api-ref/rtc/javascript/interfaces/networkquality
+
+import averageSvg from "@/assets/network/average.svg?react"
+import goodSvg from "@/assets/network/good.svg?react"
+import poorSvg from "@/assets/network/poor.svg?react"
+import disconnectedSvg from "@/assets/network/disconnected.svg?react"
+import excellentSvg from "@/assets/network/excellent.svg?react"
+
+import { IconProps } from "../types"
+
+interface INetworkIconProps extends IconProps {
+  level?: number
+}
+
+export const NetworkIcon = (props: INetworkIconProps) => {
+  const { level, ...rest } = props
+  switch (level) {
+    case 0:
+      return disconnectedSvg(rest)
+    case 1:
+      return excellentSvg(rest)
+    case 2:
+      return goodSvg(rest)
+    case 3:
+      return averageSvg(rest)
+    case 4:
+      return averageSvg(rest)
+    case 5:
+      return poorSvg(rest)
+    case 6:
+      return disconnectedSvg(rest)
+    default:
+      return disconnectedSvg(rest)
+  }
+}

+ 17 - 0
src/components/icons/setting/index.tsx

@@ -0,0 +1,17 @@
+import settingSvg from "@/assets/setting.svg?react"
+import { IconProps } from "../types"
+
+interface ISettingIconProps extends IconProps {
+  disabled?: boolean
+}
+
+export const SettingIcon = (props: ISettingIconProps) => {
+  const { disabled, ...rest } = props
+
+  const color = disabled ? "#98A2B3" : "#667085"
+
+  return settingSvg({
+    color,
+    ...rest,
+  })
+}

+ 6 - 0
src/components/icons/time/index.tsx

@@ -0,0 +1,6 @@
+import { IconProps } from "../types"
+import timeSvg from "@/assets/time.svg?react"
+
+export const TimeIcon = (props: IconProps) => {
+  return timeSvg(props)
+}

+ 17 - 0
src/components/icons/transcription/index.tsx

@@ -0,0 +1,17 @@
+import { IconProps } from "../types"
+import transcriptionSvg from "@/assets/transcription.svg?react"
+
+interface ITranscriptionIconProps extends IconProps {
+  active?: boolean
+}
+
+export const TranscriptionIcon = (props: ITranscriptionIconProps) => {
+  const { active, ...rest } = props
+
+  const color = active ? "#3D53F5" : "#667085"
+
+  return transcriptionSvg({
+    color,
+    ...rest,
+  })
+}

+ 5 - 0
src/components/icons/types.ts

@@ -0,0 +1,5 @@
+export interface IconProps {
+  width?: number
+  height?: number
+  color?: string
+}

+ 10 - 0
src/components/icons/upload/index.tsx

@@ -0,0 +1,10 @@
+import aiSvg from "@/assets/upload.svg?react"
+import { IconProps } from "../types"
+
+export const UploadIcon = (props: IconProps) => {
+  const { ...rest } = props
+
+  return aiSvg({
+    ...rest,
+  })
+}

+ 23 - 0
src/components/menu/index.module.scss

@@ -0,0 +1,23 @@
+.menu {
+  position: relative;
+  width: 400px;
+  height: 100%;
+  gap: 10px;
+  flex-shrink: 0;
+  border-radius: 3px 3px 0px 0px;
+  border-left: 1px solid #eaecf0;
+  box-sizing: border-box;
+
+  .borderLeft{
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 1px;
+    bottom: 0;
+    z-index: 999;
+    box-sizing: border-box;
+    cursor: col-resize;
+  }
+
+
+}

+ 77 - 0
src/components/menu/index.tsx

@@ -0,0 +1,77 @@
+import { useRef, useMemo, useEffect, useState } from "react"
+import MenuTitle from "./menu-title"
+import MenuContent from "./menu-content"
+import { RootState } from "@/store"
+import { useSelector } from "react-redux"
+
+import styles from "./index.module.scss"
+
+let menuWidth = 0
+let startX = 0
+let isDragging = false
+
+const Menu = () => {
+  const memberListShow = useSelector((state: RootState) => state.global.memberListShow)
+  const borderLeftRef = useRef<HTMLDivElement>(null)
+  const menuRef = useRef<HTMLDivElement>(null)
+
+  const menuInitWith = useMemo(() => {
+    return menuRef.current?.offsetWidth ?? 0
+  }, [menuRef.current])
+
+  const MAX_WIDTH = useMemo(() => {
+    const MEMBER_LIST_WIDTH = 200
+    const CENTER_MIN_WIDTH = 200
+    const screenWidth = window.innerWidth
+    return screenWidth - (memberListShow ? MEMBER_LIST_WIDTH : 0) - CENTER_MIN_WIDTH
+  }, [memberListShow])
+
+  useEffect(() => {
+    if (!borderLeftRef.current) {
+      return
+    }
+    borderLeftRef.current.addEventListener("mousedown", handleMouseDown)
+    document.addEventListener("mousemove", handleMouseMove)
+    document.addEventListener("mouseup", handleMouseUp)
+
+    return () => {
+      borderLeftRef.current?.removeEventListener("mousedown", handleMouseDown)
+      document.removeEventListener("mousemove", handleMouseMove)
+      document.removeEventListener("mouseup", handleMouseUp)
+    }
+  }, [MAX_WIDTH])
+
+  const handleMouseDown = (e: MouseEvent) => {
+    startX = e.clientX
+    menuWidth = menuRef.current!.offsetWidth
+    isDragging = true
+  }
+
+  const handleMouseMove = (e: MouseEvent) => {
+    if (isDragging) {
+      const MIN_WIDTH = menuInitWith
+      const diff = startX - e.clientX
+      let newMenuWidth = menuWidth + diff
+      if (newMenuWidth > MAX_WIDTH) {
+        newMenuWidth = MAX_WIDTH
+      } else if (newMenuWidth < MIN_WIDTH) {
+        newMenuWidth = MIN_WIDTH
+      }
+      menuRef.current!.style.width = `${newMenuWidth}px`
+    }
+  }
+
+  const handleMouseUp = () => {
+    isDragging = false
+  }
+
+  return (
+    <div className={styles.menu} ref={menuRef}>
+      <div className={styles.borderLeft} ref={borderLeftRef}></div>
+      <MenuTitle></MenuTitle>
+      <MenuContent></MenuContent>
+    </div>
+  )
+}
+
+export default Menu

+ 163 - 0
src/components/menu/menu-content/ai-assistant/index.module.scss

@@ -0,0 +1,163 @@
+.aiAssistant {
+  position: absolute;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  padding: 12px;
+  box-sizing: border-box;
+  overflow-y: auto;
+
+  .prompt {
+    .text {
+      color: var(--Grey-800, #344054);
+      font-size: 14px;
+      font-weight: 500;
+      line-height: 22px;
+    }
+
+    .select {
+      width: 100%;
+      margin-top: 6px;
+      margin-bottom: 12px;
+    }
+  }
+
+  .conversation {
+    margin-top: 24px;
+
+    .text {
+      color: var(--Grey-800, #344054);
+      font-size: 14px;
+      font-weight: 500;
+      line-height: 22px;
+    }
+
+    .select {
+      margin-top: 6px;
+      margin-bottom: 12px;
+      display: flex;
+      align-items: center;
+
+      .btn {
+        position: relative;
+        margin-left: 8px;
+        display: flex;
+        height: 32px;
+        padding: 5px 8px;
+        align-items: center;
+        gap: 8px;
+        border-radius: 4px;
+        border: 1px solid var(--Grey-400, #d0d5dd);
+        background: var(---white, #fff);
+        cursor: pointer;
+        box-sizing: border-box;
+
+        .text {
+          color: var(--Grey-600, #667085);
+          font-size: 14px;
+          font-weight: 400;
+          line-height: 22px;
+          /* 157.143% */
+        }
+
+        input {
+          position: absolute;
+          left: 0;
+          top: 0;
+          right: 0;
+          bottom: 0;
+          visibility: hidden;
+          z-index: -1;
+        }
+      }
+    }
+  }
+
+  .result {
+    margin-top: 24px;
+
+    .text {
+      margin-bottom: 6px;
+      color: var(---8_colorTitle, #344054);
+      font-size: 14px;
+      font-weight: 500;
+      line-height: 22px;
+      /* 157.143% */
+    }
+
+    .textAreaWrapper {
+      position: relative;
+
+      .cusTextarea {
+        height: 260px;
+        // resize: none;
+      }
+
+      .btn {
+        position: absolute;
+        right: 12px;
+        bottom: 13px;
+        display: flex;
+        padding: 3px 12px;
+        justify-content: center;
+        align-items: center;
+        gap: 4px;
+        color: var(--primary-500-base, #3d53f5);
+        font-size: 12px;
+        font-weight: 400;
+        line-height: 18px;
+        border-radius: 4px;
+        background: var(--Primary-50-T, #eceefe);
+        cursor: pointer;
+      }
+    }
+  }
+
+  .btnWrapper {
+    margin-top: 24px;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    .btnAnalyze {
+      display: flex;
+      height: 36px;
+      width: 160px;
+      flex: 0 0 160px;
+      padding: 8px 14px;
+      justify-content: center;
+      align-items: center;
+      gap: 8px;
+      border-radius: 6px;
+      background: var(--Primary-50-T, #eceefe);
+      color: var(--primary-500-base, #3d53f5);
+      font-size: 14px;
+      font-weight: 500;
+      line-height: 20px;
+      /* 142.857% */
+      cursor: pointer;
+      box-sizing: border-box;
+    }
+
+    .btnClearAll {
+      display: flex;
+      height: 36px;
+      padding: 8px 14px;
+      justify-content: center;
+      align-items: center;
+      gap: 8px;
+      width: 160px;
+      flex: 0 0 160px;
+      border-radius: 6px;
+      background: var(--primary-500-base, #3d53f5);
+      color: var(---white, #fff);
+      font-size: 14px;
+      font-weight: 500;
+      line-height: 20px;
+      /* 142.857% */
+      cursor: pointer;
+      box-sizing: border-box;
+    }
+  }
+}

+ 185 - 0
src/components/menu/menu-content/ai-assistant/index.tsx

@@ -0,0 +1,185 @@
+import { Input, Select, Upload } from "antd"
+import { LoadingOutlined } from "@ant-design/icons"
+import {
+  AI_PROMPT_PLACEHOLDER,
+  AI_RESULT_PLACEHOLDER,
+  AI_PROMPT_OPTIONS,
+  AI_USER_CONTENT_OPTIONS,
+  apiAiAnalysis,
+} from "@/common"
+import { UploadIcon } from "@/components/icons"
+import { forwardRef, useMemo, useRef, useState, useImperativeHandle } from "react"
+
+import styles from "./index.module.scss"
+import { useTranslation } from "react-i18next"
+
+const { TextArea } = Input
+
+interface AiAssistantProps {}
+
+export interface AiAssistantHandler {
+  setConversation: (value: string) => void
+}
+
+const AiAssistant = forwardRef((props: AiAssistantProps, ref) => {
+  const { t } = useTranslation()
+  const [systemText, setSystemText] = useState("")
+  const [system, setSystem] = useState("")
+  const [contentText, setContentText] = useState("")
+  const [resultText, setResultText] = useState("")
+  const [conversationSelectValue, setConversationSelectValue] = useState("")
+  const [loading, setLoading] = useState(false)
+  const fileInputRef = useRef<HTMLInputElement>(null)
+
+  useImperativeHandle(ref, () => {
+    return {
+      setConversation(value) {
+        setContentText(value)
+      },
+    } as AiAssistantHandler
+  })
+
+  const aiUserContentOptions = useMemo(() => {
+    return AI_USER_CONTENT_OPTIONS.filter((item) => item.type === system).map((item) => ({
+      label: item.label,
+      value: item.label,
+    }))
+  }, [system])
+
+  const onPromptChange = (value: string) => {
+    setSystem(value)
+    setConversationSelectValue("")
+    // setContentText("")
+    const target = AI_PROMPT_OPTIONS.find((item) => item.label === value)
+    if (target) {
+      setSystemText(target.value)
+    }
+  }
+
+  const onConversationChange = (value: string) => {
+    setConversationSelectValue(value)
+    const target = AI_USER_CONTENT_OPTIONS.find((item) => item.label === value)
+    if (target) {
+      setContentText(target.value)
+    }
+  }
+
+  const onSystemTextChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
+    setSystemText(e.target.value)
+  }
+
+  const onConversationTextChange = (
+    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
+  ) => {
+    setContentText(e.target.value)
+  }
+
+  const onClickClearResult = () => {
+    setResultText("")
+  }
+
+  const onClickClearAll = () => {
+    setSystemText("")
+    setContentText("")
+    setResultText("")
+  }
+
+  const onClickAnalyze = async () => {
+    if (loading) {
+      return
+    }
+    setLoading(true)
+    const res = await apiAiAnalysis({ system: systemText, userContent: contentText })
+    setResultText(res.result)
+    setLoading(false)
+  }
+
+  const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0]
+    if (file) {
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        const content = e.target?.result as string
+        setContentText(content)
+      }
+      reader.readAsText(file)
+    }
+  }
+
+  const onClickUpload = () => {
+    fileInputRef.current?.click()
+  }
+
+  return (
+    <div className={styles.aiAssistant}>
+      <div className={styles.prompt}>
+        <div className={styles.text}>{t("prompt")}</div>
+        <Select
+          placeholder={t("ai.selectPromptSample")}
+          className={styles.select}
+          options={AI_PROMPT_OPTIONS.map((item) => ({
+            label: item.label,
+            value: item.label,
+          }))}
+          onChange={onPromptChange}
+        ></Select>
+        <TextArea
+          rows={10}
+          placeholder={AI_PROMPT_PLACEHOLDER}
+          value={systemText}
+          onChange={onSystemTextChange}
+        ></TextArea>
+      </div>
+      <div className={styles.conversation}>
+        <div className={styles.text}>{t("conversationText")}</div>
+        <div className={styles.select}>
+          <Select
+            style={{ width: 210 }}
+            placeholder={t("ai.selectConversation")}
+            options={aiUserContentOptions}
+            onChange={onConversationChange}
+            value={conversationSelectValue}
+          ></Select>
+          <span className={styles.btn} onClick={onClickUpload}>
+            <input accept=".txt" type="file" onChange={onFileChange} ref={fileInputRef}></input>
+            <span className={styles.text}>{t("loadText")}</span>
+            <UploadIcon></UploadIcon>
+          </span>
+        </div>
+        <TextArea
+          rows={10}
+          placeholder="-"
+          value={contentText}
+          onChange={onConversationTextChange}
+        ></TextArea>
+      </div>
+      <div className={styles.result}>
+        <div className={styles.text}>{t("analysisResult")}</div>
+        <div className={styles.textAreaWrapper}>
+          <TextArea
+            rows={10}
+            placeholder={AI_RESULT_PLACEHOLDER}
+            classNames={{
+              textarea: styles.cusTextarea,
+            }}
+            value={resultText}
+          ></TextArea>
+          {/* <span className={styles.btn} onClick={onClickClearResult}>
+            Clear Result
+          </span> */}
+        </div>
+      </div>
+      <div className={styles.btnWrapper}>
+        <span className={styles.btnAnalyze} onClick={onClickAnalyze}>
+          {t("analyze")}
+          {loading ? <LoadingOutlined></LoadingOutlined> : null}
+        </span>
+        <span className={styles.btnClearAll} onClick={onClickClearAll}>
+          {t("clearAll")}
+        </span>
+      </div>
+    </div>
+  )
+})
+
+export default AiAssistant

+ 37 - 0
src/components/menu/menu-content/dialogue-record/index.module.scss

@@ -0,0 +1,37 @@
+.dialogRecord {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  overflow: hidden;
+
+
+  .btnWrapper {
+    position: absolute;
+    right: 20px;
+    bottom: 12px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    gap: 4px;
+
+    .btn {
+      padding: 3px 12px;
+      border-radius: 4px;
+      background: var(--primary-500-base, #3d53f5);
+      color: var(---white, #fff);
+      font-family: "Helvetica Neue";
+      font-size: 12px;
+      font-weight: 400;
+      line-height: 18px;
+      z-index: 1;
+      box-sizing: border-box;
+      cursor: pointer;
+    }
+  }
+
+}

+ 77 - 0
src/components/menu/menu-content/dialogue-record/index.tsx

@@ -0,0 +1,77 @@
+import RecordContent from "./record-content"
+import { RootState } from "@/store"
+import { addMessage } from "@/store/reducers/global"
+import { useSelector, useDispatch } from "react-redux"
+import { downloadText, showAIModule, genContentText } from "@/common"
+import LanguageShowDialog from "@/components/dialog/language-show"
+import LanguageStorageDialog from "@/components/dialog/language-storage"
+import RecordHeader from "./record-header"
+
+import styles from "./index.module.scss"
+import { useTranslation } from "react-i18next"
+import { useEffect, useMemo, useRef, useState } from "react"
+
+interface DialogueRecordProps {
+  onExport?: (value: string) => void
+}
+
+const DialogueRecord = (props: DialogueRecordProps) => {
+  const { onExport } = props
+  const dispatch = useDispatch()
+  const { t } = useTranslation()
+  const sttSubtitles = useSelector((state: RootState) => state.global.sttSubtitles)
+  const [openLanguageShowDialog, setOpenLanguageShowDialog] = useState(false)
+  const [openLanguageStorageDialog, setOpenLanguageStorageDialog] = useState(false)
+
+  const onClickStorage = () => {
+    setOpenLanguageStorageDialog(true)
+  }
+
+  const onClickExport = () => {
+    const content = genContentText(sttSubtitles)
+    onExport?.(content)
+    dispatch(addMessage({ type: "success", content: t("export.success") }))
+  }
+
+  return (
+    <div className={styles.dialogRecord}>
+      <RecordHeader
+        onClickSetting={() => {
+          setOpenLanguageShowDialog(!openLanguageShowDialog)
+        }}
+      ></RecordHeader>
+      <RecordContent></RecordContent>
+      {sttSubtitles.length ? (
+        <div className={styles.btnWrapper}>
+          {showAIModule() ? (
+            <div className={styles.btn} onClick={onClickExport}>
+              {t("export.text")}
+            </div>
+          ) : null}
+          <div className={styles.btn} onClick={onClickStorage}>
+            {t("storage.text")}
+          </div>
+        </div>
+      ) : null}
+      <LanguageShowDialog
+        open={openLanguageShowDialog}
+        onCancel={() => {
+          setOpenLanguageShowDialog(false)
+        }}
+        onOk={() => {
+          setOpenLanguageShowDialog(false)
+        }}
+      ></LanguageShowDialog>
+      <LanguageStorageDialog
+        open={openLanguageStorageDialog}
+        onCancel={() => {
+          setOpenLanguageStorageDialog(false)
+        }}
+        onOk={() => {
+          setOpenLanguageStorageDialog(false)
+        }}
+      ></LanguageStorageDialog>
+    </div>
+  )
+}
+export default DialogueRecord

+ 89 - 0
src/components/menu/menu-content/dialogue-record/record-content/index.module.scss

@@ -0,0 +1,89 @@
+.record {
+  flex: 1 1 auto;
+  width: 100%;
+  padding-left: 12px;
+  padding-right: 12px;
+  padding-bottom: 12px;
+  overflow-y: auto;
+  box-sizing: border-box;
+
+
+  .item+.item {
+    margin-top: 12px;
+  }
+
+  .item {
+    display: flex;
+    flex-direction: row;
+    align-items: flex-start;
+    width: 100%;
+    box-sizing: border-box;
+
+    .left {
+      flex:  0 0 auto;
+    }
+
+    .right {
+      flex:  1 1 auto;
+      margin-left: 12px;
+      display: flex;
+      flex-direction: column;
+      align-items: flex-start;
+
+      .up {
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+
+        .userName {
+          color: var(--Grey-800, #344054);
+          font-size: 12px;
+          font-weight: 400;
+          line-height: 18px;
+        }
+
+        .time {
+          margin-left: 4px;
+          color: var(--Grey-400, #d0d5dd);
+          font-size: 12px;
+          font-weight: 400;
+          line-height: 18px;
+        }
+      }
+
+      .bottom {
+        width: 100%;
+        margin-top: 8px;
+        box-sizing: border-box;
+
+        .content {
+          width: 100%;
+          color: var(--Grey-800, #344054);
+          font-size: 14px;
+          font-weight: 400;
+          line-height: 150%;
+        }
+
+        .translate {
+          margin-top: 8px;
+          color: var(--Grey-800, #344054);
+          font-size: 14px;
+          font-weight: 400;
+          line-height: 150%;
+          width: 100%;
+          box-sizing: border-box;
+
+          .bold {
+            font-weight: 600;
+          }
+
+          .arabic{
+            direction: rtl;
+          }
+
+        }
+
+      }
+    }
+  }
+}

+ 117 - 0
src/components/menu/menu-content/dialogue-record/record-content/index.tsx

@@ -0,0 +1,117 @@
+import Avatar from "@/components/avatar"
+import { RootState } from "@/store"
+import { formatTime2, isArabic } from "@/common"
+import { IChatItem } from "@/types"
+import { useSelector } from "react-redux"
+import { useEffect, useMemo, useRef, useState } from "react"
+
+import styles from "./index.module.scss"
+
+let lastScrollTop = 0
+
+const RecordContent = () => {
+  const recordLanguageSelect = useSelector((state: RootState) => state.global.recordLanguageSelect)
+  const languageSelect = useSelector((state: RootState) => state.global.languageSelect)
+  const subtitles = useSelector((state: RootState) => state.global.sttSubtitles)
+  const { translate1List = [], translate2List = [] } = recordLanguageSelect
+  const { transcribe1, transcribe2 } = languageSelect
+  const contentRef = useRef<HTMLElement>(null)
+  const [humanScroll, setHumanScroll] = useState(false)
+
+  const chatList: IChatItem[] = useMemo(() => {
+    const reslist: IChatItem[] = []
+    subtitles.forEach((el) => {
+      if (el.lang == transcribe1) {
+        const chatItem: IChatItem = {
+          userName: el.username,
+          content: el.text,
+          translations: [],
+          startTextTs: el.startTextTs,
+          textTs: el.textTs,
+          time: el.startTextTs,
+        }
+        el.translations?.forEach((tran) => {
+          if (translate1List.includes(tran.lang)) {
+            const tranItem = { lang: tran.lang, text: tran.text }
+            chatItem.translations?.push(tranItem)
+          }
+        })
+        reslist.push(chatItem)
+      } else if (el.lang == transcribe2) {
+        const chatItem: IChatItem = {
+          userName: el.username,
+          content: el.text,
+          translations: [],
+          startTextTs: el.startTextTs,
+          textTs: el.textTs,
+          time: el.startTextTs,
+        }
+        el.translations?.forEach((tran) => {
+          if (translate2List.includes(tran.lang)) {
+            const tranItem = { lang: tran.lang, text: tran.text }
+            chatItem.translations?.push(tranItem)
+          }
+        })
+        reslist.push(chatItem)
+      }
+    })
+    return reslist.sort((a: IChatItem, b: IChatItem) => Number(a.time) - Number(b.time))
+  }, [translate1List, translate2List, transcribe1, transcribe2, subtitles])
+
+  const onScroll = (e: any) => {
+    const scrollTop = contentRef.current?.scrollTop ?? 0
+    if (scrollTop < lastScrollTop) {
+      setHumanScroll(true)
+    }
+    lastScrollTop = scrollTop
+  }
+
+  useEffect(() => {
+    contentRef.current?.addEventListener("scroll", onScroll)
+
+    return () => {
+      contentRef.current?.removeEventListener("scroll", onScroll)
+      lastScrollTop = 0
+    }
+  }, [contentRef])
+
+  useEffect(() => {
+    if (humanScroll) {
+      return
+    }
+    if (contentRef.current) {
+      contentRef.current.scrollTop = contentRef.current.scrollHeight
+    }
+  }, [chatList, humanScroll])
+
+  return (
+    <section className={styles.record} ref={contentRef}>
+      {chatList.map((item, index) => (
+        <div key={index} className={styles.item}>
+          <div className={styles.left}>
+            <Avatar userName={item.userName}></Avatar>
+          </div>
+          <div className={styles.right}>
+            <div className={styles.up}>
+              <div className={styles.userName}>{item.userName}</div>
+              <div className={styles.time}>{formatTime2(item.time)}</div>
+            </div>
+            <div className={styles.bottom}>
+              <div className={styles.content}>{item.content}</div>
+              {item.translations?.map((tran, index) => (
+                <div className={styles.translate} key={index}>
+                  <span className={styles.bold}>{`[${tran?.lang}]: `}</span>
+                  <span className={`${isArabic(tran?.lang) ? styles.arabic : ""}`}>
+                    {tran?.text}
+                  </span>
+                </div>
+              ))}
+            </div>
+          </div>
+        </div>
+      ))}
+    </section>
+  )
+}
+
+export default RecordContent

+ 72 - 0
src/components/menu/menu-content/dialogue-record/record-header/index.module.scss

@@ -0,0 +1,72 @@
+.header {
+  width: 100%;
+  flex: 0 0 auto;
+  box-sizing: border-box;
+  padding: 10px;
+
+  .start {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    .try {
+      width: 340px;
+      display: flex;
+      padding: 8px 10px;
+      align-items: center;
+      justify-content: space-between;
+      gap: 4px;
+      align-self: stretch;
+      border-radius: 8px;
+      background: var(--Primary-50-T, #eceefe);
+      font-size: 0;
+      box-sizing: border-box;
+
+      .text {
+        color: var(--Grey-800, #344054);
+        font-size: 12px;
+        font-weight: 400;
+        line-height: 22px;
+
+        .time {
+          display: inline-flex;
+          width: 30px;
+        }
+      }
+
+      .btn {
+        display: flex;
+        padding: 3px 12px;
+        justify-content: center;
+        align-items: center;
+        gap: 4px;
+        border-radius: 4px;
+        background: var(--primary-500-base, #3d53f5);
+        color: var(---White, #fff);
+        font-family: "Helvetica Neue";
+        font-size: 12px;
+        font-weight: 400;
+        line-height: 18px;
+        white-space: nowrap;
+        cursor: pointer;
+      }
+    }
+
+    .setting {}
+
+  }
+
+
+  .conversation {
+    margin-top: 12px;
+    margin-bottom: 12px;
+    color: var(--Grey-500, #98a2b3);
+    text-align: center;
+    font-family: "PingFang SC";
+    font-size: 12px;
+    font-style: normal;
+    font-weight: 400;
+    line-height: 18px;
+  }
+
+}

+ 93 - 0
src/components/menu/menu-content/dialogue-record/record-header/index.tsx

@@ -0,0 +1,93 @@
+import { useEffect, useRef, useMemo, useState } from "react"
+import { useDispatch, useSelector } from "react-redux"
+import { RootState } from "@/store"
+import { useResizeObserver, formatTime } from "@/common"
+import { SettingIcon } from "@/components/icons"
+import { useTranslation } from "react-i18next"
+
+import styles from "./index.module.scss"
+
+interface IRecordHeaderProps {
+  onClickSetting?: () => void
+}
+
+const RecordHeader = (props: IRecordHeaderProps) => {
+  const dispatch = useDispatch()
+  const { t } = useTranslation()
+  const sttData = useSelector((state: RootState) => state.global.sttData)
+  const [experienceDuration, setExperienceDuration] = useState(0)
+  const headerRef = useRef<HTMLDivElement>(null)
+  const tryRef = useRef<HTMLDivElement>(null)
+  const headerDimensions = useResizeObserver(headerRef)
+
+  useEffect(() => {
+    if (!tryRef?.current) {
+      return
+    }
+    if (headerDimensions.width >= 600) {
+      tryRef.current.style.width = "500px"
+    } else if (headerDimensions.width >= 500) {
+      tryRef.current.style.width = "420px"
+    }
+  }, [headerDimensions])
+
+  useEffect(() => {
+    let timer: any
+
+    if (sttData.status == "start") {
+      timer = setInterval(async () => {
+        if (sttData.startTime && sttData.duration) {
+          const now = new Date().getTime()
+          const duration = Math.floor((sttData.startTime + sttData.duration - now) / 1000)
+          if (duration >= 0) {
+            setExperienceDuration(duration)
+          }
+        }
+      }, 1000)
+    }
+
+    return () => {
+      timer && clearInterval(timer)
+    }
+  }, [sttData])
+
+  const onClickExtend = () => {
+    window.sttManager.extendDuration({
+      startTime: new Date().getTime(),
+    })
+  }
+
+  const onClickSetting = () => {
+    props?.onClickSetting?.()
+  }
+
+  return (
+    <section ref={headerRef} className={styles.header}>
+      {sttData.status == "start" ? (
+        <>
+          <div className={styles.start}>
+            <div className={styles.try} ref={tryRef}>
+              <span className={styles.text}>
+                {t("conversation.onTrial")} &nbsp;
+                <span className={styles.time}>{formatTime(experienceDuration)}</span> &nbsp;
+                {/* <span>{t("conversation.extendExperienceText")}</span> */}
+              </span>
+              {/* <span className={styles.btn} onClick={onClickExtend}>
+                {t("conversation.extendExperience")}
+              </span> */}
+            </div>
+            {/* setting */}
+            <div className={styles.setting} onClick={onClickSetting}>
+              <SettingIcon></SettingIcon>
+            </div>
+          </div>
+          <div className={styles.conversation}>{t("conversation.sttStarted")}</div>
+        </>
+      ) : (
+        <div className={styles.conversation}>{t("conversation.sttStopped")}</div>
+      )}
+    </section>
+  )
+}
+
+export default RecordHeader

+ 5 - 0
src/components/menu/menu-content/index.module.scss

@@ -0,0 +1,5 @@
+.menuContent {
+  position: relative;
+  width: 100%;
+  height: calc(100% - 40px);
+}

+ 34 - 0
src/components/menu/menu-content/index.tsx

@@ -0,0 +1,34 @@
+import { useSelector, useDispatch } from "react-redux"
+import { addMenuItem } from "@/store/reducers/global"
+import AiAssistant, { AiAssistantHandler } from "./ai-assistant"
+import DialogueRecord from "./dialogue-record"
+import { RootState } from "@/store"
+import { useRef } from "react"
+
+import styles from "./index.module.scss"
+
+const MenuContent = () => {
+  const dispatch = useDispatch()
+  const menuList = useSelector((state: RootState) => state.global.menuList)
+  const activeType = menuList[0]
+  const aiAssistantRef = useRef<AiAssistantHandler>(null)
+
+  const onExport = (value: string) => {
+    dispatch(addMenuItem("AI"))
+    setTimeout(() => {
+      aiAssistantRef.current?.setConversation(value)
+    }, 0)
+  }
+
+  return (
+    <div className={styles.menuContent}>
+      {activeType === "AI" ? (
+        <AiAssistant ref={aiAssistantRef}></AiAssistant>
+      ) : (
+        <DialogueRecord onExport={onExport}></DialogueRecord>
+      )}
+    </div>
+  )
+}
+
+export default MenuContent

+ 71 - 0
src/components/menu/menu-title/index.module.scss

@@ -0,0 +1,71 @@
+.title {
+  flex: 1 0 0;
+  height: 40px;
+  box-sizing: border-box;
+  border-radius: 3px 3px 0px 0px;
+  border-bottom: 1px solid var(--Grey-300, #eaecf0);
+  background: #fff;
+
+  .titleOne {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    padding: 0px 8px;
+    justify-content: space-between;
+    align-items: center;
+    box-sizing: border-box;
+
+    .text {
+      flex: 1 1 auto;
+      margin-left: 4px;
+      color: var(--Grey-800, #344054);
+      font-size: 14px;
+      font-weight: 500;
+      line-height: 150%;
+      /* 21px */
+      letter-spacing: 0.449px;
+    }
+  }
+
+  .titleTwo {
+    display: flex;
+    position: relative;
+    width: 100%;
+    height: 100%;
+    box-sizing: border-box;
+    background: var(--Grey-200, #f2f4f7);
+
+    .item {
+      display: flex;
+      width: auto;
+      height: 40px;
+      padding: 0px 8px;
+      justify-content: space-between;
+      align-items: center;
+      box-sizing: border-box;
+      font-size: 0;
+      border-bottom: 1px solid var(--Grey-300, #eaecf0);
+      cursor: pointer;
+
+      &:global(.active) {
+        background-color: #fff;
+      }
+
+      .text {
+        flex: 1 1 auto;
+        margin-left: 4px;
+        margin-right: 8px;
+        color: var(--Grey-800, #344054);
+        font-size: 14px;
+        font-weight: 500;
+        line-height: 150%;
+        letter-spacing: 0.449px;
+      }
+    }
+
+    .item:first-child {
+      border-right: 1px solid var(--Grey-300, #eaecf0);
+    }
+  }
+}

+ 82 - 0
src/components/menu/menu-title/index.tsx

@@ -0,0 +1,82 @@
+import { TranscriptionIcon, AiIcon } from "@/components/icons"
+import { CloseOutlined } from "@ant-design/icons"
+import { RootState } from "@/store"
+import { useSelector, useDispatch } from "react-redux"
+import React, { useEffect, useMemo, useRef, useState } from "react"
+import { MenuType } from "@/types"
+import {
+  setAIShow,
+  setDialogRecordShow,
+  removeMenuItem,
+  addMenuItem,
+} from "@/store/reducers/global"
+
+import styles from "./index.module.scss"
+import { useTranslation } from "react-i18next"
+
+const MenuTitle = () => {
+  const dispatch = useDispatch()
+  const { t } = useTranslation()
+  const menuList = useSelector((state: RootState) => state.global.menuList)
+  const activeType = menuList[0]
+
+  const TitleOneText = useMemo(() => {
+    if (activeType == "AI") {
+      return t("footer.aIAssistant")
+    } else {
+      return t("footer.conversationHistory")
+    }
+  }, [activeType])
+
+  const onClickClose = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (activeType === "AI") {
+      dispatch(setAIShow(false))
+      dispatch(removeMenuItem("AI"))
+    } else {
+      dispatch(setDialogRecordShow(false))
+      dispatch(removeMenuItem("DialogRecord"))
+    }
+  }
+
+  const onClickItem = (type: MenuType) => {
+    dispatch(addMenuItem(type))
+  }
+
+  return (
+    <div className={styles.title}>
+      {menuList.length == 1 ? (
+        <div className={styles.titleOne}>
+          <TranscriptionIcon width={16} height={16}></TranscriptionIcon>
+          <span className={styles.text}>{TitleOneText}</span>
+          <CloseOutlined style={{ fontSize: "12px" }} onClick={onClickClose} />
+        </div>
+      ) : (
+        <div className={styles.titleTwo}>
+          <span
+            className={`${styles.item} ${activeType == "DialogRecord" ? "active" : ""}`}
+            onClick={() => onClickItem("DialogRecord")}
+          >
+            <TranscriptionIcon width={16} height={16}></TranscriptionIcon>
+            <span className={styles.text}>{t("footer.conversationHistory")}</span>
+            {activeType == "DialogRecord" ? (
+              <CloseOutlined style={{ fontSize: "12px" }} onClick={onClickClose} />
+            ) : null}
+          </span>
+          <span
+            className={`${styles.item} ${activeType == "AI" ? "active" : ""}`}
+            onClick={() => onClickItem("AI")}
+          >
+            <AiIcon width={16} height={16}></AiIcon>
+            <span className={styles.text}>{t("footer.aIAssistant")}</span>
+            {activeType == "AI" ? (
+              <CloseOutlined style={{ fontSize: "12px" }} onClick={onClickClose} />
+            ) : null}
+          </span>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default MenuTitle

+ 6 - 0
src/components/stream-player/index.module.scss

@@ -0,0 +1,6 @@
+.streamPlayer {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}

+ 2 - 0
src/components/stream-player/index.tsx

@@ -0,0 +1,2 @@
+export * from "./localStreamPlayer"
+export * from "./remoteStreamPlayer"

+ 46 - 0
src/components/stream-player/localStreamPlayer.tsx

@@ -0,0 +1,46 @@
+import {
+  ICameraVideoTrack,
+  IMicrophoneAudioTrack,
+  IRemoteAudioTrack,
+  IRemoteVideoTrack,
+  VideoPlayerConfig,
+} from "agora-rtc-sdk-ng"
+import { useRef, useState, useLayoutEffect, forwardRef, useEffect, useMemo } from "react"
+import { useSelector } from "react-redux"
+import { RootState } from "@/store"
+
+import styles from "./index.module.scss"
+
+interface StreamPlayerProps {
+  videoTrack?: ICameraVideoTrack
+  audioTrack?: IMicrophoneAudioTrack
+  style?: React.CSSProperties
+  fit?: "cover" | "contain" | "fill"
+  onClick?: () => void
+}
+
+export const LocalStreamPlayer = forwardRef((props: StreamPlayerProps, ref) => {
+  const { videoTrack, audioTrack, style = {}, fit = "cover", onClick = () => {} } = props
+  const localVideoMute = useSelector((state: RootState) => state.global.localVideoMute)
+  const vidDiv = useRef(null)
+
+  useLayoutEffect(() => {
+    const config = { fit } as VideoPlayerConfig
+    if (localVideoMute) {
+      videoTrack?.stop()
+    } else {
+      if (!videoTrack?.isPlaying) {
+        videoTrack?.play(vidDiv.current!, config)
+      }
+    }
+
+    return () => {
+      videoTrack?.stop()
+    }
+  }, [videoTrack, fit, localVideoMute])
+
+  // local audio track need not to be played
+  // useLayoutEffect(() => {}, [audioTrack, localAudioMute])
+
+  return <div className={styles.streamPlayer} style={style} ref={vidDiv} onClick={onClick}></div>
+})

+ 45 - 0
src/components/stream-player/remoteStreamPlayer.tsx

@@ -0,0 +1,45 @@
+import {
+  ICameraVideoTrack,
+  IMicrophoneAudioTrack,
+  IRemoteAudioTrack,
+  IRemoteVideoTrack,
+  VideoPlayerConfig,
+} from "agora-rtc-sdk-ng"
+import { useRef, useState, useLayoutEffect, forwardRef, useEffect, useMemo } from "react"
+import { useSelector } from "react-redux"
+import { RootState } from "@/store"
+
+import styles from "./index.module.scss"
+
+interface StreamPlayerProps {
+  videoTrack?: ICameraVideoTrack | IRemoteVideoTrack
+  audioTrack?: IMicrophoneAudioTrack | IRemoteAudioTrack
+  style?: React.CSSProperties
+  fit?: "cover" | "contain" | "fill"
+  onClick?: () => void
+}
+
+export const RemoteStreamPlayer = forwardRef((props: StreamPlayerProps, ref) => {
+  const { videoTrack, audioTrack, style = {}, fit = "cover", onClick = () => {} } = props
+
+  const vidDiv = useRef(null)
+
+  useLayoutEffect(() => {
+    const config = { fit } as VideoPlayerConfig
+    if (!videoTrack?.isPlaying) {
+      videoTrack?.play(vidDiv.current!, config)
+    }
+
+    return () => {
+      videoTrack?.stop()
+    }
+  }, [videoTrack, fit])
+
+  useLayoutEffect(() => {
+    if (!audioTrack?.isPlaying) {
+      audioTrack?.play()
+    }
+  }, [audioTrack])
+
+  return <div className={styles.streamPlayer} style={style} ref={vidDiv} onClick={onClick}></div>
+})

+ 45 - 0
src/components/user-list/index.module.scss

@@ -0,0 +1,45 @@
+.userList {
+  display: flex;
+  width: 200px;
+  height: 100%;
+  flex-direction: column;
+  align-items: center;
+  flex-shrink: 0;
+  border-right: 1px solid #eaecf0;
+  background: #fff;
+  box-sizing: border-box;
+
+  .title {
+    display: flex;
+    padding: 12px;
+    justify-content: space-between;
+    align-items: center;
+    align-self: stretch;
+    border-bottom: 1px solid #eaecf0;
+    background: #fff;
+
+    .text {
+      color: #344054;
+      font-size: 14px;
+      font-weight: 500;
+      line-height: 150%;
+      letter-spacing: 0.449px;
+    }
+  }
+
+  .content {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+
+    .list {
+      position: absolute;
+      left: 0;
+      top: 0;
+      right: 0;
+      bottom: 0;
+      overflow-y: auto;
+    }
+  }
+}

+ 43 - 0
src/components/user-list/index.tsx

@@ -0,0 +1,43 @@
+import { useDispatch } from "react-redux"
+import { setMemberListShow } from "@/store/reducers/global"
+import { CloseOutlined } from "@ant-design/icons"
+import UserItem from "./user-item"
+import { IUserData } from "@/types"
+import { useTranslation } from "react-i18next"
+
+import styles from "./index.module.scss"
+
+interface IUserListProps {
+  data?: IUserData[]
+  onClickItem?: (data: IUserData) => void
+}
+
+const UserList = (props: IUserListProps) => {
+  const { data = [], onClickItem = () => {} } = props
+  const dispatch = useDispatch()
+  const { t } = useTranslation()
+
+  const onClickClose = () => {
+    dispatch(setMemberListShow(false))
+  }
+
+  return (
+    <div className={styles.userList}>
+      <div className={styles.title}>
+        <span className={styles.text}>
+          {t("footer.participantsList")} ({data.length + 1})
+        </span>
+        <CloseOutlined onClick={onClickClose} />
+      </div>
+      <div className={styles.content}>
+        <div className={styles.list}>
+          {data.map((item) => {
+            return <UserItem data={item} key={item.userId} onClick={onClickItem}></UserItem>
+          })}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default UserList

Some files were not shown because too many files changed in this diff