From 23066da8214f910be2f22f48fe8641296facdf15 Mon Sep 17 00:00:00 2001 From: Jay Zhang Date: Wed, 2 Oct 2024 18:43:22 +0800 Subject: [PATCH] Merge dev/0.5.0 (#304) * feat: doc update * Feature/debugging (#271) * feat: upgrade to ten 0.2 * feat: disable auto_start * feat: allow customize build type * feat: add debugging tools * feat: add debugging and build configs * fix: go debugging launch * feat: update image * feat: update version * fix: greeting missed * test: build docker * Revert "test: build docker" This reverts commit 06a426946c621f33f0dcb9fcb3337acae0fe009f. --------- Co-authored-by: Jay Zhang * feat: switch to native env * Feat/refactor exts (#276) * feat: openai extension refactoring * feat: adding refactor code / async.io * feat: fix refactoring bugs * fix: add manifest.json * feat: add queue logic * fix: fix issues - remove test code - prevent sending full content again - add queue logic * feat: fix parseSentence * fix: fix end_segment bug * feat: add chatflow abstraction - chatflow - refactor to simplify flow run - added event emitter for intermedium execution * feat: refactor openai, support multi data-stream data pack * feat: finalize openai extension refactoring - change asyncio.queue to AsyncQueue - change the way we abstract chatflow - use eventEmitter for easier tool notification - use queue to ensure task are processed one by one and cancellable * feat: add docs * feat: don't use private api * feat: support on-demand build * fix: fix type failures * feat: update playground image * feat: separate playground and demo (#282) - support graph reading for playground - support property adjusting * fix: fix table type issue * feat: image update * feat: adjust demo ui * feat: update build image (#301) * feat:use axios instead of fetch * replace all fetch in demo * replace all fetch in playground with axios --------- Co-authored-by: Zhang Qianze Co-authored-by: Ethan Zhang --- .devcontainer/devcontainer.json | 8 +- .github/workflows/build-docker.yaml | 82 +++-- .vscode/launch.json | 47 +++ .vscode/tasks.json | 22 ++ Dockerfile | 2 +- agents/main.go | 1 - agents/manifest-lock.json | 59 ++-- agents/manifest.json | 13 +- agents/property.json | 148 ++++---- agents/scripts/install_deps_and_build.sh | 7 +- .../bak/openai_chatgpt_python/__init__.py | 4 + .../bak/openai_chatgpt_python/log.py | 13 + .../bak/openai_chatgpt_python/manifest.json | 93 +++++ .../openai_chatgpt_python/openai_chatgpt.py | 0 .../openai_chatgpt_addon.py | 0 .../openai_chatgpt_extension.py | 0 .../bak/openai_chatgpt_python/property.json | 1 + .../openai_chatgpt_python/requirements.txt | 5 + .../manifest.json | 2 +- .../aliyun_text_embedding/manifest.json | 2 +- .../extension/azure_tts/manifest.json | 2 +- .../bedrock_llm_python/manifest.json | 2 +- .../extension/chat_transcriber/manifest.json | 2 +- .../chat_transcriber_python/manifest.json | 2 +- .../extension/cosy_tts/manifest.json | 2 +- .../extension/elevenlabs_tts/manifest.json | 2 +- .../elevenlabs_tts_python/manifest.json | 2 +- .../extension/file_chunker/manifest.json | 2 +- .../extension/gemini_llm_python/manifest.json | 2 +- .../http_server_python/manifest.json | 4 +- .../interrupt_detector/manifest.json | 2 +- .../interrupt_detector_python/manifest.json | 2 +- .../llama_index_chat_engine/manifest.json | 2 +- .../extension/message_collector/manifest.json | 2 +- .../message_collector/src/extension.py | 79 ++++- .../extension/openai_chatgpt/manifest.json | 2 +- .../extension/openai_chatgpt_python/BUILD.gn | 21 ++ .../extension/openai_chatgpt_python/README.md | 60 ++++ .../openai_chatgpt_python/__init__.py | 9 +- .../extension/openai_chatgpt_python/addon.py | 22 ++ .../openai_chatgpt_python/extension.py | 318 ++++++++++++++++++ .../extension/openai_chatgpt_python/helper.py | 187 ++++++++++ .../extension/openai_chatgpt_python/log.py | 13 +- .../openai_chatgpt_python/manifest.json | 14 +- .../extension/openai_chatgpt_python/openai.py | 125 +++++++ .../openai_chatgpt_python/requirements.txt | 4 +- .../extension/polly_tts/manifest.json | 2 +- .../extension/qwen_llm_python/manifest.json | 2 +- .../transcribe_asr_python/manifest.json | 2 +- demo/.dockerignore | 3 + demo/.env | 1 + demo/.gitignore | 135 ++++++++ demo/Dockerfile | 27 ++ demo/LICENSE | 21 ++ demo/next-env.d.ts | 5 + demo/next.config.mjs | 37 ++ demo/package.json | 42 +++ demo/postcss.config.js | 10 + demo/src/app/api/agents/start/graph.tsx | 159 +++++++++ demo/src/app/api/agents/start/route.tsx | 61 ++++ demo/src/app/favicon.ico | Bin 0 -> 15406 bytes demo/src/app/global.css | 67 ++++ {playground => demo}/src/app/home/page.tsx | 0 demo/src/app/index.module.scss | 93 +++++ demo/src/app/layout.tsx | 53 +++ demo/src/app/page.tsx | 14 + demo/src/assets/background.jpg | Bin 0 -> 95856 bytes demo/src/assets/cam_mute.svg | 3 + demo/src/assets/cam_unmute.svg | 3 + demo/src/assets/color_picker.svg | 17 + demo/src/assets/github.svg | 3 + demo/src/assets/info.svg | 3 + demo/src/assets/logo.svg | 46 +++ demo/src/assets/logo_small.svg | 33 ++ demo/src/assets/mic_mute.svg | 3 + demo/src/assets/mic_unmute.svg | 3 + demo/src/assets/network/average.svg | 7 + demo/src/assets/network/disconnected.svg | 9 + demo/src/assets/network/excellent.svg | 6 + demo/src/assets/network/good.svg | 7 + demo/src/assets/network/poor.svg | 7 + demo/src/assets/pdf.svg | 3 + demo/src/assets/transcription.svg | 5 + demo/src/assets/voice.svg | 3 + demo/src/common/constant.ts | 88 +++++ demo/src/common/hooks.ts | 131 ++++++++ demo/src/common/index.ts | 6 + demo/src/common/mock.ts | 41 +++ demo/src/common/request.ts | 99 ++++++ demo/src/common/storage.ts | 21 ++ demo/src/common/utils.ts | 59 ++++ demo/src/components/authInitializer/index.tsx | 29 ++ .../components/customSelect/index.module.scss | 22 ++ demo/src/components/customSelect/index.tsx | 19 ++ demo/src/components/icons/cam/index.tsx | 17 + .../components/icons/colorPicker/index.tsx | 6 + demo/src/components/icons/github/index.tsx | 6 + demo/src/components/icons/index.tsx | 10 + demo/src/components/icons/info/index.tsx | 6 + demo/src/components/icons/logo/index.tsx | 8 + demo/src/components/icons/mic/index.tsx | 23 ++ demo/src/components/icons/network/index.tsx | 33 ++ demo/src/components/icons/pdf/index.tsx | 6 + .../components/icons/transcription/index.tsx | 6 + demo/src/components/icons/types.ts | 10 + demo/src/components/icons/voice/index.tsx | 6 + .../components/loginCard/index.module.scss | 112 ++++++ demo/src/components/loginCard/index.tsx | 77 +++++ .../components/pdfSelect/index.module.scss | 8 + demo/src/components/pdfSelect/index.tsx | 88 +++++ .../pdfSelect/upload/index.module.scss | 7 + .../src/components/pdfSelect/upload/index.tsx | 75 +++++ demo/src/manager/events.ts | 51 +++ demo/src/manager/index.ts | 1 + demo/src/manager/rtc/index.ts | 2 + demo/src/manager/rtc/rtc.ts | 199 +++++++++++ demo/src/manager/rtc/types.ts | 25 ++ demo/src/manager/types.ts | 1 + demo/src/middleware.tsx | 44 +++ .../mobile/chat/chatItem/index.module.scss | 86 +++++ .../platform/mobile/chat/chatItem/index.tsx | 50 +++ .../platform/mobile/chat/index.module.scss | 78 +++++ demo/src/platform/mobile/chat/index.tsx | 65 ++++ .../mobile/description/index.module.scss | 71 ++++ .../src/platform/mobile/description/index.tsx | 100 ++++++ .../platform/mobile/entry/index.module.scss | 18 + demo/src/platform/mobile/entry/index.tsx | 30 ++ .../platform/mobile/header/index.module.scss | 57 ++++ demo/src/platform/mobile/header/index.tsx | 48 +++ .../header/infoPopover/index.module.scss | 43 +++ .../mobile/header/infoPopover/index.tsx | 57 ++++ .../mobile/header/network/index.module.scss | 0 .../platform/mobile/header/network/index.tsx | 37 ++ .../colorPicker/index.module.scss | 24 ++ .../header/stylePopover/colorPicker/index.tsx | 22 ++ .../header/stylePopover/index.module.scss | 51 +++ .../mobile/header/stylePopover/index.tsx | 54 +++ demo/src/platform/mobile/menu/context.ts | 9 + .../platform/mobile/menu/index.module.scss | 69 ++++ demo/src/platform/mobile/menu/index.tsx | 76 +++++ .../mobile/rtc/agent/index.module.scss | 31 ++ demo/src/platform/mobile/rtc/agent/index.tsx | 34 ++ .../rtc/audioVisualizer/index.module.scss | 17 + .../mobile/rtc/audioVisualizer/index.tsx | 48 +++ .../camSection/camSelect/index.module.scss | 4 + .../mobile/rtc/camSection/camSelect/index.tsx | 57 ++++ .../mobile/rtc/camSection/index.module.scss | 54 +++ .../platform/mobile/rtc/camSection/index.tsx | 42 +++ .../src/platform/mobile/rtc/index.module.scss | 55 +++ demo/src/platform/mobile/rtc/index.tsx | 128 +++++++ .../mobile/rtc/micSection/index.module.scss | 58 ++++ .../platform/mobile/rtc/micSection/index.tsx | 70 ++++ .../micSection/micSelect/index.module.scss | 4 + .../mobile/rtc/micSection/micSelect/index.tsx | 58 ++++ .../mobile/rtc/streamPlayer/index.module.scss | 6 + .../mobile/rtc/streamPlayer/index.tsx | 1 + .../rtc/streamPlayer/localStreamPlayer.tsx | 46 +++ .../pc/chat/chatItem/index.module.scss | 90 +++++ demo/src/platform/pc/chat/chatItem/index.tsx | 51 +++ demo/src/platform/pc/chat/index.module.scss | 79 +++++ demo/src/platform/pc/chat/index.tsx | 66 ++++ .../platform/pc/description/index.module.scss | 73 ++++ demo/src/platform/pc/description/index.tsx | 101 ++++++ demo/src/platform/pc/entry/index.module.scss | 27 ++ demo/src/platform/pc/entry/index.tsx | 26 ++ demo/src/platform/pc/header/index.module.scss | 58 ++++ demo/src/platform/pc/header/index.tsx | 48 +++ .../pc/header/infoPopover/index.module.scss | 43 +++ .../platform/pc/header/infoPopover/index.tsx | 57 ++++ .../pc/header/network/index.module.scss | 0 demo/src/platform/pc/header/network/index.tsx | 37 ++ .../colorPicker/index.module.scss | 24 ++ .../header/stylePopover/colorPicker/index.tsx | 22 ++ .../pc/header/stylePopover/index.module.scss | 51 +++ .../platform/pc/header/stylePopover/index.tsx | 54 +++ .../platform/pc/rtc/agent/index.module.scss | 31 ++ demo/src/platform/pc/rtc/agent/index.tsx | 34 ++ .../pc/rtc/audioVisualizer/index.module.scss | 17 + .../platform/pc/rtc/audioVisualizer/index.tsx | 48 +++ .../camSection/camSelect/index.module.scss | 4 + .../pc/rtc/camSection/camSelect/index.tsx | 57 ++++ .../pc/rtc/camSection/index.module.scss | 54 +++ demo/src/platform/pc/rtc/camSection/index.tsx | 47 +++ demo/src/platform/pc/rtc/index.module.scss | 55 +++ demo/src/platform/pc/rtc/index.tsx | 128 +++++++ .../pc/rtc/micSection/index.module.scss | 56 +++ demo/src/platform/pc/rtc/micSection/index.tsx | 73 ++++ .../micSection/micSelect/index.module.scss | 4 + .../pc/rtc/micSection/micSelect/index.tsx | 58 ++++ .../pc/rtc/streamPlayer/index.module.scss | 6 + .../platform/pc/rtc/streamPlayer/index.tsx | 1 + .../pc/rtc/streamPlayer/localStreamPlayer.tsx | 46 +++ demo/src/protobuf/SttMessage.js | 0 demo/src/protobuf/SttMessage.proto | 40 +++ demo/src/protobuf/SttMessage_es6.js | 134 ++++++++ demo/src/store/index.ts | 21 ++ demo/src/store/provider/index.tsx | 21 ++ demo/src/store/reducers/global.ts | 105 ++++++ demo/src/types/index.ts | 62 ++++ demo/tsconfig.json | 40 +++ docker-compose.yml | 15 +- playground/.env | 3 +- playground/package.json | 27 +- playground/src/app/api/agents/start/route.tsx | 51 +++ playground/src/app/global.css | 1 + playground/src/app/page.tsx | 39 ++- playground/src/common/hooks.ts | 13 + playground/src/common/request.ts | 90 +++-- .../src/components/authInitializer/index.tsx | 10 +- .../src/components/pdfSelect/upload/index.tsx | 1 + playground/src/manager/rtc/rtc.ts | 121 ++++--- playground/src/middleware.tsx | 22 +- .../src/platform/mobile/description/index.tsx | 5 +- .../mobile/rtc/agent/index.module.scss | 2 - .../src/platform/mobile/rtc/agent/index.tsx | 1 - playground/src/platform/mobile/rtc/index.tsx | 1 - playground/src/platform/pc/chat/index.tsx | 90 ++++- .../src/platform/pc/chat/table/index.tsx | 168 +++++++++ .../src/platform/pc/description/index.tsx | 16 +- .../src/platform/pc/entry/index.module.scss | 13 +- playground/src/platform/pc/entry/index.tsx | 8 +- .../platform/pc/rtc/agent/index.module.scss | 2 - .../src/platform/pc/rtc/agent/index.tsx | 1 - playground/src/platform/pc/rtc/index.tsx | 14 - playground/src/store/reducers/global.ts | 22 +- playground/src/types/index.ts | 1 - server/internal/http_server.go | 40 +-- 227 files changed, 7816 insertions(+), 398 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 agents/ten_packages/bak/openai_chatgpt_python/__init__.py create mode 100644 agents/ten_packages/bak/openai_chatgpt_python/log.py create mode 100644 agents/ten_packages/bak/openai_chatgpt_python/manifest.json rename agents/ten_packages/{extension => bak}/openai_chatgpt_python/openai_chatgpt.py (100%) rename agents/ten_packages/{extension => bak}/openai_chatgpt_python/openai_chatgpt_addon.py (100%) rename agents/ten_packages/{extension => bak}/openai_chatgpt_python/openai_chatgpt_extension.py (100%) create mode 100644 agents/ten_packages/bak/openai_chatgpt_python/property.json create mode 100644 agents/ten_packages/bak/openai_chatgpt_python/requirements.txt create mode 100644 agents/ten_packages/extension/openai_chatgpt_python/BUILD.gn create mode 100644 agents/ten_packages/extension/openai_chatgpt_python/README.md create mode 100644 agents/ten_packages/extension/openai_chatgpt_python/addon.py create mode 100644 agents/ten_packages/extension/openai_chatgpt_python/extension.py create mode 100644 agents/ten_packages/extension/openai_chatgpt_python/helper.py create mode 100644 agents/ten_packages/extension/openai_chatgpt_python/openai.py create mode 100644 demo/.dockerignore create mode 100644 demo/.env create mode 100644 demo/.gitignore create mode 100644 demo/Dockerfile create mode 100644 demo/LICENSE create mode 100644 demo/next-env.d.ts create mode 100644 demo/next.config.mjs create mode 100644 demo/package.json create mode 100644 demo/postcss.config.js create mode 100644 demo/src/app/api/agents/start/graph.tsx create mode 100644 demo/src/app/api/agents/start/route.tsx create mode 100644 demo/src/app/favicon.ico create mode 100644 demo/src/app/global.css rename {playground => demo}/src/app/home/page.tsx (100%) create mode 100644 demo/src/app/index.module.scss create mode 100644 demo/src/app/layout.tsx create mode 100644 demo/src/app/page.tsx create mode 100644 demo/src/assets/background.jpg create mode 100644 demo/src/assets/cam_mute.svg create mode 100644 demo/src/assets/cam_unmute.svg create mode 100644 demo/src/assets/color_picker.svg create mode 100644 demo/src/assets/github.svg create mode 100644 demo/src/assets/info.svg create mode 100644 demo/src/assets/logo.svg create mode 100644 demo/src/assets/logo_small.svg create mode 100644 demo/src/assets/mic_mute.svg create mode 100644 demo/src/assets/mic_unmute.svg create mode 100644 demo/src/assets/network/average.svg create mode 100644 demo/src/assets/network/disconnected.svg create mode 100644 demo/src/assets/network/excellent.svg create mode 100644 demo/src/assets/network/good.svg create mode 100644 demo/src/assets/network/poor.svg create mode 100644 demo/src/assets/pdf.svg create mode 100644 demo/src/assets/transcription.svg create mode 100644 demo/src/assets/voice.svg create mode 100644 demo/src/common/constant.ts create mode 100644 demo/src/common/hooks.ts create mode 100644 demo/src/common/index.ts create mode 100644 demo/src/common/mock.ts create mode 100644 demo/src/common/request.ts create mode 100644 demo/src/common/storage.ts create mode 100644 demo/src/common/utils.ts create mode 100644 demo/src/components/authInitializer/index.tsx create mode 100644 demo/src/components/customSelect/index.module.scss create mode 100644 demo/src/components/customSelect/index.tsx create mode 100644 demo/src/components/icons/cam/index.tsx create mode 100644 demo/src/components/icons/colorPicker/index.tsx create mode 100644 demo/src/components/icons/github/index.tsx create mode 100644 demo/src/components/icons/index.tsx create mode 100644 demo/src/components/icons/info/index.tsx create mode 100644 demo/src/components/icons/logo/index.tsx create mode 100644 demo/src/components/icons/mic/index.tsx create mode 100644 demo/src/components/icons/network/index.tsx create mode 100644 demo/src/components/icons/pdf/index.tsx create mode 100644 demo/src/components/icons/transcription/index.tsx create mode 100644 demo/src/components/icons/types.ts create mode 100644 demo/src/components/icons/voice/index.tsx create mode 100644 demo/src/components/loginCard/index.module.scss create mode 100644 demo/src/components/loginCard/index.tsx create mode 100644 demo/src/components/pdfSelect/index.module.scss create mode 100644 demo/src/components/pdfSelect/index.tsx create mode 100644 demo/src/components/pdfSelect/upload/index.module.scss create mode 100644 demo/src/components/pdfSelect/upload/index.tsx create mode 100644 demo/src/manager/events.ts create mode 100644 demo/src/manager/index.ts create mode 100644 demo/src/manager/rtc/index.ts create mode 100644 demo/src/manager/rtc/rtc.ts create mode 100644 demo/src/manager/rtc/types.ts create mode 100644 demo/src/manager/types.ts create mode 100644 demo/src/middleware.tsx create mode 100644 demo/src/platform/mobile/chat/chatItem/index.module.scss create mode 100644 demo/src/platform/mobile/chat/chatItem/index.tsx create mode 100644 demo/src/platform/mobile/chat/index.module.scss create mode 100644 demo/src/platform/mobile/chat/index.tsx create mode 100644 demo/src/platform/mobile/description/index.module.scss create mode 100644 demo/src/platform/mobile/description/index.tsx create mode 100644 demo/src/platform/mobile/entry/index.module.scss create mode 100644 demo/src/platform/mobile/entry/index.tsx create mode 100644 demo/src/platform/mobile/header/index.module.scss create mode 100644 demo/src/platform/mobile/header/index.tsx create mode 100644 demo/src/platform/mobile/header/infoPopover/index.module.scss create mode 100644 demo/src/platform/mobile/header/infoPopover/index.tsx create mode 100644 demo/src/platform/mobile/header/network/index.module.scss create mode 100644 demo/src/platform/mobile/header/network/index.tsx create mode 100644 demo/src/platform/mobile/header/stylePopover/colorPicker/index.module.scss create mode 100644 demo/src/platform/mobile/header/stylePopover/colorPicker/index.tsx create mode 100644 demo/src/platform/mobile/header/stylePopover/index.module.scss create mode 100644 demo/src/platform/mobile/header/stylePopover/index.tsx create mode 100644 demo/src/platform/mobile/menu/context.ts create mode 100644 demo/src/platform/mobile/menu/index.module.scss create mode 100644 demo/src/platform/mobile/menu/index.tsx create mode 100644 demo/src/platform/mobile/rtc/agent/index.module.scss create mode 100644 demo/src/platform/mobile/rtc/agent/index.tsx create mode 100644 demo/src/platform/mobile/rtc/audioVisualizer/index.module.scss create mode 100644 demo/src/platform/mobile/rtc/audioVisualizer/index.tsx create mode 100644 demo/src/platform/mobile/rtc/camSection/camSelect/index.module.scss create mode 100644 demo/src/platform/mobile/rtc/camSection/camSelect/index.tsx create mode 100644 demo/src/platform/mobile/rtc/camSection/index.module.scss create mode 100644 demo/src/platform/mobile/rtc/camSection/index.tsx create mode 100644 demo/src/platform/mobile/rtc/index.module.scss create mode 100644 demo/src/platform/mobile/rtc/index.tsx create mode 100644 demo/src/platform/mobile/rtc/micSection/index.module.scss create mode 100644 demo/src/platform/mobile/rtc/micSection/index.tsx create mode 100644 demo/src/platform/mobile/rtc/micSection/micSelect/index.module.scss create mode 100644 demo/src/platform/mobile/rtc/micSection/micSelect/index.tsx create mode 100644 demo/src/platform/mobile/rtc/streamPlayer/index.module.scss create mode 100644 demo/src/platform/mobile/rtc/streamPlayer/index.tsx create mode 100644 demo/src/platform/mobile/rtc/streamPlayer/localStreamPlayer.tsx create mode 100644 demo/src/platform/pc/chat/chatItem/index.module.scss create mode 100644 demo/src/platform/pc/chat/chatItem/index.tsx create mode 100644 demo/src/platform/pc/chat/index.module.scss create mode 100644 demo/src/platform/pc/chat/index.tsx create mode 100644 demo/src/platform/pc/description/index.module.scss create mode 100644 demo/src/platform/pc/description/index.tsx create mode 100644 demo/src/platform/pc/entry/index.module.scss create mode 100644 demo/src/platform/pc/entry/index.tsx create mode 100644 demo/src/platform/pc/header/index.module.scss create mode 100644 demo/src/platform/pc/header/index.tsx create mode 100644 demo/src/platform/pc/header/infoPopover/index.module.scss create mode 100644 demo/src/platform/pc/header/infoPopover/index.tsx create mode 100644 demo/src/platform/pc/header/network/index.module.scss create mode 100644 demo/src/platform/pc/header/network/index.tsx create mode 100644 demo/src/platform/pc/header/stylePopover/colorPicker/index.module.scss create mode 100644 demo/src/platform/pc/header/stylePopover/colorPicker/index.tsx create mode 100644 demo/src/platform/pc/header/stylePopover/index.module.scss create mode 100644 demo/src/platform/pc/header/stylePopover/index.tsx create mode 100644 demo/src/platform/pc/rtc/agent/index.module.scss create mode 100644 demo/src/platform/pc/rtc/agent/index.tsx create mode 100644 demo/src/platform/pc/rtc/audioVisualizer/index.module.scss create mode 100644 demo/src/platform/pc/rtc/audioVisualizer/index.tsx create mode 100644 demo/src/platform/pc/rtc/camSection/camSelect/index.module.scss create mode 100644 demo/src/platform/pc/rtc/camSection/camSelect/index.tsx create mode 100644 demo/src/platform/pc/rtc/camSection/index.module.scss create mode 100644 demo/src/platform/pc/rtc/camSection/index.tsx create mode 100644 demo/src/platform/pc/rtc/index.module.scss create mode 100644 demo/src/platform/pc/rtc/index.tsx create mode 100644 demo/src/platform/pc/rtc/micSection/index.module.scss create mode 100644 demo/src/platform/pc/rtc/micSection/index.tsx create mode 100644 demo/src/platform/pc/rtc/micSection/micSelect/index.module.scss create mode 100644 demo/src/platform/pc/rtc/micSection/micSelect/index.tsx create mode 100644 demo/src/platform/pc/rtc/streamPlayer/index.module.scss create mode 100644 demo/src/platform/pc/rtc/streamPlayer/index.tsx create mode 100644 demo/src/platform/pc/rtc/streamPlayer/localStreamPlayer.tsx create mode 100644 demo/src/protobuf/SttMessage.js create mode 100644 demo/src/protobuf/SttMessage.proto create mode 100644 demo/src/protobuf/SttMessage_es6.js create mode 100644 demo/src/store/index.ts create mode 100644 demo/src/store/provider/index.tsx create mode 100644 demo/src/store/reducers/global.ts create mode 100644 demo/src/types/index.ts create mode 100644 demo/tsconfig.json create mode 100644 playground/src/app/api/agents/start/route.tsx create mode 100644 playground/src/platform/pc/chat/table/index.tsx diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e899eb94..bf7a4c1c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,11 +2,12 @@ // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile { "name": "astra", - "image": "ghcr.io/ten-framework/astra_agents_build:0.4.0", + "image": "ghcr.io/ten-framework/ten_agent_build:0.1.0", "customizations": { "vscode": { "extensions": [ - "golang.go" + "golang.go", + "ms-vscode.cpptools" ] } }, @@ -19,6 +20,7 @@ ], // Features to add to the dev container. More info: https://containers.dev/features. "features": { - "ghcr.io/devcontainers/features/git:1": {} + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/python:1": {} } } \ No newline at end of file diff --git a/.github/workflows/build-docker.yaml b/.github/workflows/build-docker.yaml index df45ecc6..68f59b50 100644 --- a/.github/workflows/build-docker.yaml +++ b/.github/workflows/build-docker.yaml @@ -1,16 +1,16 @@ name: Build Docker -on: +on: push: - branches: [ "main" ] + branches: ["main"] # Publish semver tags as releases. - tags: [ 'v*.*.*' ] + tags: ["v*.*.*"] paths-ignore: - - '.devcontainer/**' - - '.github/ISSUE_TEMPLATE/**' - - 'images/**' - - 'playground/**' - - '**.md' + - ".devcontainer/**" + - ".github/ISSUE_TEMPLATE/**" + - "images/**" + - "playground/**" + - "**.md" pull_request: branches: [ "main" ] workflow_dispatch: @@ -18,36 +18,46 @@ on: env: SERVER_IMAGE_NAME: astra_agents_server PLAYGROUND_IMAGE_NAME: astra_playground + DEMO_IMAGE_NAME: agent_demo jobs: build: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-tags: true - fetch-depth: '0' - - id: pre-step - shell: bash - run: echo "image-tag=$(git describe --tags --always)" >> $GITHUB_OUTPUT - - name: Build & Publish Docker Image for Agents Server - uses: elgohr/Publish-Docker-Github-Action@v5 - with: - name: ${{ github.repository_owner }}/${{ env.SERVER_IMAGE_NAME }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - registry: ghcr.io - tags: "${{ github.ref == 'refs/heads/main' && 'latest,' || '' }}${{ steps.pre-step.outputs.image-tag }}" - no_push: ${{ github.event_name == 'pull_request' }} - - name: Build & Publish Docker Image for Playground - uses: elgohr/Publish-Docker-Github-Action@v5 - with: - name: ${{ github.repository_owner }}/${{ env.PLAYGROUND_IMAGE_NAME }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - registry: ghcr.io - workdir: playground - tags: "${{ github.ref == 'refs/heads/main' && 'latest,' || '' }}${{ steps.pre-step.outputs.image-tag }}" - no_push: ${{ github.event_name == 'pull_request' }} - + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: "0" + - id: pre-step + shell: bash + run: echo "image-tag=$(git describe --tags --always)" >> $GITHUB_OUTPUT + - name: Build & Publish Docker Image for Agents Server + uses: elgohr/Publish-Docker-Github-Action@v5 + with: + name: ${{ github.repository_owner }}/${{ env.SERVER_IMAGE_NAME }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + registry: ghcr.io + tags: "${{ github.ref == 'refs/heads/main' && 'latest,' || '' }}${{ steps.pre-step.outputs.image-tag }}" + no_push: ${{ github.event_name == 'pull_request' }} + - name: Build & Publish Docker Image for Playground + uses: elgohr/Publish-Docker-Github-Action@v5 + with: + name: ${{ github.repository_owner }}/${{ env.PLAYGROUND_IMAGE_NAME }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + registry: ghcr.io + workdir: playground + tags: "${{ github.ref == 'refs/heads/main' && 'latest,' || '' }}${{ steps.pre-step.outputs.image-tag }}" + no_push: ${{ github.event_name == 'pull_request' }} + - name: Build & Publish Docker Image for demo + uses: elgohr/Publish-Docker-Github-Action@v5 + with: + name: ${{ github.repository_owner }}/${{ env.DEMO_IMAGE_NAME }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + registry: ghcr.io + workdir: demo + tags: "${{ github.ref == 'refs/heads/main' && 'latest,' || '' }}${{ steps.pre-step.outputs.image-tag }}" + no_push: ${{ github.event_name == 'pull_request' }} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..8795a5f2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,47 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "debug go", + "type": "go", + "request": "launch", + "mode": "exec", + "cwd": "${workspaceFolder}", + "program": "${workspaceFolder}/agents/bin/worker", + "env": { + "LD_LIBRARY_PATH": "${workspaceFolder}/agents/ten_packages/system/ten_runtime_go/lib:${workspaceFolder}/agents/ten_packages/system/agora_rtc_sdk/lib:${workspaceFolder}/agents/ten_packages/system/azure_speech_sdk/lib", + "TEN_APP_BASE_DIR": "${workspaceFolder}/agents" + } + }, + { + "name": "debug python", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "preLaunchTask": "start app" + }, + { + "name": "debug cpp", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/agents/bin/worker", + "cwd": "${workspaceFolder}", + "environment": [ + { + "name": "LD_LIBRARY_PATH", + "value": "${workspaceFolder}/agents/ten_packages/system/agora_rtc_sdk/lib:${workspaceFolder}/agents/ten_packages/system/azure_speech_sdk/lib" + }, + { + "name": "CGO_LDFLAGS", + "value": "-L${workspaceFolder}/agents/ten_packages/system/ten_runtime_go/lib -lten_runtime_go -Wl,-rpath,@loader_path/lib -Wl,-rpath,@loader_path/../lib" + } + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..da11248b --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,22 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "shell", + "label": "build", + "command": "make build", + "args": [], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "start app", + "type": "shell", + "command": "export TEN_ENABLE_PYTHON_DEBUG=true; export TEN_PYTHON_DEBUG_PORT=5678; ./agents/bin/start", + "group": "none", + "isBackground": true + }, + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 77792a4c..97adf23a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/ten-framework/astra_agents_build:0.4.0 AS builder +FROM ghcr.io/ten-framework/ten_agent_build:0.1.0 AS builder ARG SESSION_CONTROL_CONF=session_control.conf diff --git a/agents/main.go b/agents/main.go index 61107505..b42101f5 100644 --- a/agents/main.go +++ b/agents/main.go @@ -50,7 +50,6 @@ func startAppBlocking(cfg *appConfig) { appInstance.Run(true) appInstance.Wait() - ten.UnloadAllAddons() ten.EnsureCleanupWhenProcessExit() } diff --git a/agents/manifest-lock.json b/agents/manifest-lock.json index 09b454c8..902dca63 100644 --- a/agents/manifest-lock.json +++ b/agents/manifest-lock.json @@ -1,10 +1,11 @@ { + "version": 1, "packages": [ { "type": "system", "name": "ten_runtime_go", - "version": "0.1.0", - "hash": "ab66a8ed40c744a52cce36f26a233e669d989d9f620876156e3cc7187f214977", + "version": "0.2.0", + "hash": "8b582de5dfaa38983104143fbb6c530b3aeb463ad9e9ef51f2c72fba9862b8cc", "dependencies": [ { "type": "system", @@ -21,8 +22,8 @@ { "type": "extension", "name": "py_init_extension_cpp", - "version": "0.1.0", - "hash": "b39c4dddbec58e1a756b7e71f41ab0ec419ab0043525ba94e6ed98b7ef634697", + "version": "0.2.0", + "hash": "e1858dfd83d18a69901cefb2edfd52e57ee326a3d306e799ff1d661f3195bb6b", "dependencies": [ { "type": "system", @@ -43,8 +44,8 @@ { "type": "extension_group", "name": "default_extension_group", - "version": "0.1.0", - "hash": "cfadaf8f951de42965e92becc67a597501196bc3bd6f17f39a64260836393c64", + "version": "0.2.0", + "hash": "117ed3e747654fc1282129a160eaecc2cd16548e70aa22128efee21f10e185c8", "dependencies": [ { "type": "system", @@ -61,8 +62,8 @@ { "type": "extension", "name": "agora_rtc", - "version": "0.5.1", - "hash": "de8bb179155f6a329072be87ccd624177e9bbe795956a9a275e7886a3dc31cb7", + "version": "0.7.0-rc2", + "hash": "89d7af8f84d06afbd79901e0057182280f3f430227ad6fae98ec154067ffa82c", "dependencies": [ { "type": "system", @@ -72,10 +73,6 @@ "type": "system", "name": "agora_rtc_sdk" }, - { - "type": "system", - "name": "azure_speech_sdk" - }, { "type": "system", "name": "nlohmann_json" @@ -88,11 +85,23 @@ } ] }, + { + "type": "system", + "name": "azure_speech_sdk", + "version": "1.38.0", + "hash": "66a50ef361f8190fa0595d8298c135e13b73796d57174a0802631263a8f15806", + "supports": [ + { + "os": "linux", + "arch": "x64" + } + ] + }, { "type": "extension", "name": "azure_tts", "version": "0.4.0", - "hash": "4f25e8c2a9c82f2a699a4e8378d11d2fbb31c90ec5df1718271ebfe6377a5389", + "hash": "c8f838754aaae7ed4598e99fb94b6e251382ade08fa23dbf85e91e9864d018ef", "dependencies": [ { "type": "system", @@ -107,20 +116,8 @@ { "type": "system", "name": "ten_runtime", - "version": "0.1.0", - "hash": "b630f52ef9787132dc854fb4e90f962336be3f836baba137ca3b6dc132df0b86", - "supports": [ - { - "os": "linux", - "arch": "x64" - } - ] - }, - { - "type": "system", - "name": "azure_speech_sdk", - "version": "1.38.0", - "hash": "66a50ef361f8190fa0595d8298c135e13b73796d57174a0802631263a8f15806", + "version": "0.2.0", + "hash": "7effdb036d5bf91894060a9230775ff8ec2598f202b8238578f99a14dbf11632", "supports": [ { "os": "linux", @@ -131,8 +128,8 @@ { "type": "system", "name": "agora_rtc_sdk", - "version": "4.1.35+build328115", - "hash": "fd33989f9913d77e05970eb2b265fa5e08322141b7e0f8f9fd3e521f87929b3b", + "version": "4.1.36+build331418", + "hash": "74115dd35822dc3b09fbadb577b31146af704d60435ca401c0b305cce80b2ba4", "supports": [ { "os": "linux", @@ -149,8 +146,8 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1.0", - "hash": "a8980c39ba0cf1f21b38490c3167950f750403b1b29d756cfbacc5c5147becd7", + "version": "0.2.0", + "hash": "b44d3767f364583f8bb8e8995f6f7f49e3af27b3c9a8ddf62fa319f1fc39910e", "dependencies": [ { "type": "system", diff --git a/agents/manifest.json b/agents/manifest.json index 4dfb524e..4ba7b513 100644 --- a/agents/manifest.json +++ b/agents/manifest.json @@ -6,22 +6,27 @@ { "type": "system", "name": "ten_runtime_go", - "version": "0.1" + "version": "0.2" }, { "type": "extension", "name": "py_init_extension_cpp", - "version": "0.1" + "version": "0.2" }, { "type": "extension_group", "name": "default_extension_group", - "version": "0.1" + "version": "0.2" }, { "type": "extension", "name": "agora_rtc", - "version": "=0.5.1" + "version": "=0.7.0-rc2" + }, + { + "type": "system", + "name": "azure_speech_sdk", + "version": "1.38.0" }, { "type": "extension", diff --git a/agents/property.json b/agents/property.json index 880241c7..c21b4baf 100644 --- a/agents/property.json +++ b/agents/property.json @@ -4,7 +4,7 @@ "predefined_graphs": [ { "name": "va.openai.azure", - "auto_start": true, + "auto_start": false, "nodes": [ { "type": "extension", @@ -12,7 +12,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -23,8 +23,8 @@ "enable_agora_asr": true, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf" } }, @@ -41,7 +41,7 @@ "name": "openai_chatgpt", "property": { "base_url": "", - "api_key": "$OPENAI_API_KEY", + "api_key": "${env:OPENAI_API_KEY}", "frequency_penalty": 0.9, "model": "gpt-4o-mini", "max_tokens": 512, @@ -57,8 +57,8 @@ "addon": "azure_tts", "name": "azure_tts", "property": { - "azure_subscription_key": "$AZURE_TTS_KEY", - "azure_subscription_region": "$AZURE_TTS_REGION", + "azure_subscription_key": "${env:AZURE_TTS_KEY}", + "azure_subscription_region": "${env:AZURE_TTS_REGION}", "azure_synthesis_voice_name": "en-US-JaneNeural" } }, @@ -211,7 +211,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -222,8 +222,8 @@ "enable_agora_asr": true, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf" } }, @@ -240,7 +240,7 @@ "name": "openai_chatgpt", "property": { "base_url": "", - "api_key": "$OPENAI_API_KEY", + "api_key": "${env:OPENAI_API_KEY}", "frequency_penalty": 0.9, "model": "gpt-4o-mini", "max_tokens": 512, @@ -256,7 +256,7 @@ "addon": "elevenlabs_tts", "name": "elevenlabs_tts", "property": { - "api_key": "$ELEVENLABS_TTS_KEY", + "api_key": "${env:ELEVENLABS_TTS_KEY}", "model_id": "eleven_multilingual_v2", "optimize_streaming_latency": 0, "request_timeout_seconds": 30, @@ -416,7 +416,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -427,8 +427,8 @@ "enable_agora_asr": true, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf" } }, @@ -439,8 +439,8 @@ "name": "bedrock_llm", "property": { "region": "us-east-1", - "access_key": "$AWS_ACCESS_KEY_ID", - "secret_key": "$AWS_SECRET_ACCESS_KEY", + "access_key": "${env:AWS_ACCESS_KEY_ID}", + "secret_key": "${env:AWS_SECRET_ACCESS_KEY}", "model": "anthropic.claude-3-5-sonnet-20240620-v1:0", "max_tokens": 512, "prompt": "", @@ -454,8 +454,8 @@ "addon": "azure_tts", "name": "azure_tts", "property": { - "azure_subscription_key": "$AZURE_TTS_KEY", - "azure_subscription_region": "$AZURE_TTS_REGION", + "azure_subscription_key": "${env:AZURE_TTS_KEY}", + "azure_subscription_region": "${env:AZURE_TTS_REGION}", "azure_synthesis_voice_name": "en-US-JaneNeural" } }, @@ -580,7 +580,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -591,8 +591,8 @@ "enable_agora_asr": true, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf" } }, @@ -603,7 +603,7 @@ "name": "openai_chatgpt", "property": { "base_url": "", - "api_key": "$OPENAI_API_KEY", + "api_key": "${env:OPENAI_API_KEY}", "frequency_penalty": 0.9, "model": "gpt-4o-mini", "max_tokens": 512, @@ -619,7 +619,7 @@ "addon": "cosy_tts", "name": "cosy_tts", "property": { - "api_key": "$QWEN_API_KEY", + "api_key": "${env:QWEN_API_KEY}", "model": "cosyvoice-v1", "voice": "longxiaochun", "sample_rate": 16000 @@ -780,7 +780,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -791,8 +791,8 @@ "enable_agora_asr": true, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf" } }, @@ -802,7 +802,7 @@ "addon": "qwen_llm_python", "name": "qwen_llm", "property": { - "api_key": "$QWEN_API_KEY", + "api_key": "${env:QWEN_API_KEY}", "model": "qwen-max", "max_tokens": 512, "prompt": "", @@ -815,7 +815,7 @@ "addon": "cosy_tts", "name": "cosy_tts", "property": { - "api_key": "$QWEN_API_KEY", + "api_key": "${env:QWEN_API_KEY}", "model": "cosyvoice-v1", "voice": "longxiaochun", "sample_rate": 16000 @@ -983,7 +983,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -994,8 +994,8 @@ "enable_agora_asr": true, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf" } }, @@ -1006,8 +1006,8 @@ "name": "bedrock_llm", "property": { "region": "us-east-1", - "access_key": "$AWS_ACCESS_KEY_ID", - "secret_key": "$AWS_SECRET_ACCESS_KEY", + "access_key": "${env:AWS_ACCESS_KEY_ID}", + "secret_key": "${env:AWS_SECRET_ACCESS_KEY}", "model": "anthropic.claude-3-5-sonnet-20240620-v1:0", "max_tokens": 512, "prompt": "", @@ -1022,8 +1022,8 @@ "name": "polly_tts", "property": { "region": "us-east-1", - "access_key": "$AWS_ACCESS_KEY_ID", - "secret_key": "$AWS_SECRET_ACCESS_KEY", + "access_key": "${env:AWS_ACCESS_KEY_ID}", + "secret_key": "${env:AWS_SECRET_ACCESS_KEY}", "engine": "generative", "voice": "Ruth", "sample_rate": "16000", @@ -1151,7 +1151,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -1162,8 +1162,8 @@ "enable_agora_asr": false, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf" } }, @@ -1174,8 +1174,8 @@ "name": "transcribe_asr", "property": { "region": "us-east-1", - "access_key": "$AWS_ACCESS_KEY_ID", - "secret_key": "$AWS_SECRET_ACCESS_KEY", + "access_key": "${env:AWS_ACCESS_KEY_ID}", + "secret_key": "${env:AWS_SECRET_ACCESS_KEY}", "sample_rate": "16000", "lang_code": "en-US" } @@ -1187,8 +1187,8 @@ "name": "bedrock_llm", "property": { "region": "us-east-1", - "access_key": "$AWS_ACCESS_KEY_ID", - "secret_key": "$AWS_SECRET_ACCESS_KEY", + "access_key": "${env:AWS_ACCESS_KEY_ID}", + "secret_key": "${env:AWS_SECRET_ACCESS_KEY}", "model": "anthropic.claude-3-5-sonnet-20240620-v1:0", "max_tokens": 512, "prompt": "", @@ -1203,8 +1203,8 @@ "name": "polly_tts", "property": { "region": "us-east-1", - "access_key": "$AWS_ACCESS_KEY_ID", - "secret_key": "$AWS_SECRET_ACCESS_KEY", + "access_key": "${env:AWS_ACCESS_KEY_ID}", + "secret_key": "${env:AWS_SECRET_ACCESS_KEY}", "engine": "generative", "voice": "Ruth", "sample_rate": "16000", @@ -1381,7 +1381,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -1393,8 +1393,8 @@ "enable_agora_asr": true, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf", "subscribe_video_pix_fmt": 4 } @@ -1412,7 +1412,7 @@ "name": "openai_chatgpt", "property": { "base_url": "", - "api_key": "$OPENAI_API_KEY", + "api_key": "${env:OPENAI_API_KEY}", "frequency_penalty": 0.9, "model": "gpt-4o", "max_tokens": 512, @@ -1430,8 +1430,8 @@ "addon": "azure_tts", "name": "azure_tts", "property": { - "azure_subscription_key": "$AZURE_TTS_KEY", - "azure_subscription_region": "$AZURE_TTS_REGION", + "azure_subscription_key": "${env:AZURE_TTS_KEY}", + "azure_subscription_region": "${env:AZURE_TTS_REGION}", "azure_synthesis_voice_name": "en-US-JaneNeural" } }, @@ -1587,7 +1587,7 @@ }, { "name": "va.gemini.azure", - "auto_start": true, + "auto_start": false, "nodes": [ { "type": "extension", @@ -1595,7 +1595,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -1606,8 +1606,8 @@ "enable_agora_asr": true, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf" } }, @@ -1640,8 +1640,8 @@ "addon": "azure_tts", "name": "azure_tts", "property": { - "azure_subscription_key": "$AZURE_TTS_KEY", - "azure_subscription_region": "$AZURE_TTS_REGION", + "azure_subscription_key": "${env:AZURE_TTS_KEY}", + "azure_subscription_region": "${env:AZURE_TTS_REGION}", "azure_synthesis_voice_name": "en-US-JaneNeural" } }, @@ -1786,7 +1786,7 @@ }, { "name": "va.qwen.rag", - "auto_start": true, + "auto_start": false, "nodes": [ { "type": "extension", @@ -1794,7 +1794,7 @@ "addon": "agora_rtc", "name": "agora_rtc", "property": { - "app_id": "$AGORA_APP_ID", + "app_id": "${env:AGORA_APP_ID}", "token": "", "channel": "astra_agents_test", "stream_id": 1234, @@ -1805,8 +1805,8 @@ "enable_agora_asr": true, "agora_asr_vendor_name": "microsoft", "agora_asr_language": "en-US", - "agora_asr_vendor_key": "$AZURE_STT_KEY", - "agora_asr_vendor_region": "$AZURE_STT_REGION", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION}", "agora_asr_session_control_file_path": "session_control.conf" } }, @@ -1816,7 +1816,7 @@ "addon": "qwen_llm_python", "name": "qwen_llm", "property": { - "api_key": "$QWEN_API_KEY", + "api_key": "${env:QWEN_API_KEY}", "model": "qwen-max", "max_tokens": 512, "prompt": "", @@ -1829,7 +1829,7 @@ "addon": "cosy_tts", "name": "cosy_tts", "property": { - "api_key": "$QWEN_API_KEY", + "api_key": "${env:QWEN_API_KEY}", "model": "cosyvoice-v1", "voice": "longxiaochun", "sample_rate": 16000 @@ -1841,8 +1841,8 @@ "addon": "azure_tts", "name": "azure_tts", "property": { - "azure_subscription_key": "$AZURE_TTS_KEY", - "azure_subscription_region": "$AZURE_TTS_REGION", + "azure_subscription_key": "${env:AZURE_TTS_KEY}", + "azure_subscription_region": "${env:AZURE_TTS_REGION}", "azure_synthesis_voice_name": "en-US-JaneNeural" } }, @@ -1874,7 +1874,7 @@ "addon": "aliyun_text_embedding", "name": "aliyun_text_embedding", "property": { - "api_key": "$ALIYUN_TEXT_EMBEDDING_API_KEY", + "api_key": "${env:ALIYUN_TEXT_EMBEDDING_API_KEY}", "model": "text-embedding-v3" } }, @@ -1884,14 +1884,14 @@ "addon": "aliyun_analyticdb_vector_storage", "name": "aliyun_analyticdb_vector_storage", "property": { - "alibaba_cloud_access_key_id": "$ALIBABA_CLOUD_ACCESS_KEY_ID", - "alibaba_cloud_access_key_secret": "$ALIBABA_CLOUD_ACCESS_KEY_SECRET", - "adbpg_instance_id": "$ALIYUN_ANALYTICDB_INSTANCE_ID", - "adbpg_instance_region": "$ALIYUN_ANALYTICDB_INSTANCE_REGION", - "adbpg_account": "$ALIYUN_ANALYTICDB_ACCOUNT", - "adbpg_account_password": "$ALIYUN_ANALYTICDB_ACCOUNT_PASSWORD", - "adbpg_namespace": "$ALIYUN_ANALYTICDB_NAMESPACE", - "adbpg_namespace_password": "$ALIYUN_ANALYTICDB_NAMESPACE_PASSWORD" + "alibaba_cloud_access_key_id": "${env:ALIBABA_CLOUD_ACCESS_KEY_ID}", + "alibaba_cloud_access_key_secret": "${env:ALIBABA_CLOUD_ACCESS_KEY_SECRET}", + "adbpg_instance_id": "${env:ALIYUN_ANALYTICDB_INSTANCE_ID}", + "adbpg_instance_region": "${env:ALIYUN_ANALYTICDB_INSTANCE_REGION}", + "adbpg_account": "${env:ALIYUN_ANALYTICDB_ACCOUNT}", + "adbpg_account_password": "${env:ALIYUN_ANALYTICDB_ACCOUNT_PASSWORD}", + "adbpg_namespace": "${env:ALIYUN_ANALYTICDB_NAMESPACE}", + "adbpg_namespace_password": "${env:ALIYUN_ANALYTICDB_NAMESPACE_PASSWORD}" } }, { diff --git a/agents/scripts/install_deps_and_build.sh b/agents/scripts/install_deps_and_build.sh index 4d1538ff..9c5977cd 100755 --- a/agents/scripts/install_deps_and_build.sh +++ b/agents/scripts/install_deps_and_build.sh @@ -6,6 +6,9 @@ OS="linux" # x64, arm64 CPU="x64" +# debug, release +BUILD_TYPE="release" + build_cxx_extensions() { local app_dir=$1 @@ -16,8 +19,8 @@ build_cxx_extensions() { cp $app_dir/scripts/BUILD.gn $app_dir - tgn gen $OS $CPU release -- is_clang=false - tgn build $OS $CPU release + tgn gen $OS $CPU $BUILD_TYPE -- is_clang=false enable_sanitizer=false + tgn build $OS $CPU $BUILD_TYPE local ret=$? diff --git a/agents/ten_packages/bak/openai_chatgpt_python/__init__.py b/agents/ten_packages/bak/openai_chatgpt_python/__init__.py new file mode 100644 index 00000000..42c4cd12 --- /dev/null +++ b/agents/ten_packages/bak/openai_chatgpt_python/__init__.py @@ -0,0 +1,4 @@ +from . import openai_chatgpt_addon +from .log import logger + +logger.info("openai_chatgpt_python extension loaded") diff --git a/agents/ten_packages/bak/openai_chatgpt_python/log.py b/agents/ten_packages/bak/openai_chatgpt_python/log.py new file mode 100644 index 00000000..fa2202da --- /dev/null +++ b/agents/ten_packages/bak/openai_chatgpt_python/log.py @@ -0,0 +1,13 @@ +import logging + +logger = logging.getLogger("openai_chatgpt_python") +logger.setLevel(logging.INFO) + +formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(process)d - [%(filename)s:%(lineno)d] - %(message)s" +) + +console_handler = logging.StreamHandler() +console_handler.setFormatter(formatter) + +logger.addHandler(console_handler) diff --git a/agents/ten_packages/bak/openai_chatgpt_python/manifest.json b/agents/ten_packages/bak/openai_chatgpt_python/manifest.json new file mode 100644 index 00000000..ce872dfe --- /dev/null +++ b/agents/ten_packages/bak/openai_chatgpt_python/manifest.json @@ -0,0 +1,93 @@ +{ + "type": "extension", + "name": "openai_chatgpt_python", + "version": "0.4.0", + "dependencies": [ + { + "type": "system", + "name": "ten_runtime_python", + "version": "0.2" + } + ], + "api": { + "property": { + "api_key": { + "type": "string" + }, + "frequency_penalty": { + "type": "float64" + }, + "presence_penalty": { + "type": "float64" + }, + "temperature": { + "type": "float64" + }, + "top_p": { + "type": "float64" + }, + "model": { + "type": "string" + }, + "max_tokens": { + "type": "int64" + }, + "base_url": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "greeting": { + "type": "string" + }, + "checking_vision_text_items": { + "type": "string" + }, + "proxy_url": { + "type": "string" + }, + "max_memory_length": { + "type": "int64" + }, + "enable_tools": { + "type": "bool" + } + }, + "data_in": [ + { + "name": "text_data", + "property": { + "text": { + "type": "string" + } + } + } + ], + "data_out": [ + { + "name": "text_data", + "property": { + "text": { + "type": "string" + } + } + } + ], + "cmd_in": [ + { + "name": "flush" + } + ], + "cmd_out": [ + { + "name": "flush" + } + ], + "video_frame_in": [ + { + "name": "video_frame" + } + ] + } +} \ No newline at end of file diff --git a/agents/ten_packages/extension/openai_chatgpt_python/openai_chatgpt.py b/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt.py similarity index 100% rename from agents/ten_packages/extension/openai_chatgpt_python/openai_chatgpt.py rename to agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt.py diff --git a/agents/ten_packages/extension/openai_chatgpt_python/openai_chatgpt_addon.py b/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_addon.py similarity index 100% rename from agents/ten_packages/extension/openai_chatgpt_python/openai_chatgpt_addon.py rename to agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_addon.py diff --git a/agents/ten_packages/extension/openai_chatgpt_python/openai_chatgpt_extension.py b/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_extension.py similarity index 100% rename from agents/ten_packages/extension/openai_chatgpt_python/openai_chatgpt_extension.py rename to agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_extension.py diff --git a/agents/ten_packages/bak/openai_chatgpt_python/property.json b/agents/ten_packages/bak/openai_chatgpt_python/property.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/agents/ten_packages/bak/openai_chatgpt_python/property.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/agents/ten_packages/bak/openai_chatgpt_python/requirements.txt b/agents/ten_packages/bak/openai_chatgpt_python/requirements.txt new file mode 100644 index 00000000..5ddef5be --- /dev/null +++ b/agents/ten_packages/bak/openai_chatgpt_python/requirements.txt @@ -0,0 +1,5 @@ +openai +numpy +requests +pillow +asyncio \ No newline at end of file diff --git a/agents/ten_packages/extension/aliyun_analyticdb_vector_storage/manifest.json b/agents/ten_packages/extension/aliyun_analyticdb_vector_storage/manifest.json index 4137a8f2..d5dd00f9 100644 --- a/agents/ten_packages/extension/aliyun_analyticdb_vector_storage/manifest.json +++ b/agents/ten_packages/extension/aliyun_analyticdb_vector_storage/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/aliyun_text_embedding/manifest.json b/agents/ten_packages/extension/aliyun_text_embedding/manifest.json index 627c044f..ea02f9e0 100644 --- a/agents/ten_packages/extension/aliyun_text_embedding/manifest.json +++ b/agents/ten_packages/extension/aliyun_text_embedding/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/azure_tts/manifest.json b/agents/ten_packages/extension/azure_tts/manifest.json index 4f9fdfe1..8c7b8e31 100644 --- a/agents/ten_packages/extension/azure_tts/manifest.json +++ b/agents/ten_packages/extension/azure_tts/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime", - "version": "0.1" + "version": "0.2" }, { "type": "system", diff --git a/agents/ten_packages/extension/bedrock_llm_python/manifest.json b/agents/ten_packages/extension/bedrock_llm_python/manifest.json index 602e5a84..7a122b9e 100644 --- a/agents/ten_packages/extension/bedrock_llm_python/manifest.json +++ b/agents/ten_packages/extension/bedrock_llm_python/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/chat_transcriber/manifest.json b/agents/ten_packages/extension/chat_transcriber/manifest.json index 0d03a858..c318eba2 100644 --- a/agents/ten_packages/extension/chat_transcriber/manifest.json +++ b/agents/ten_packages/extension/chat_transcriber/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_go", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/chat_transcriber_python/manifest.json b/agents/ten_packages/extension/chat_transcriber_python/manifest.json index ad8c8bde..f56247ba 100644 --- a/agents/ten_packages/extension/chat_transcriber_python/manifest.json +++ b/agents/ten_packages/extension/chat_transcriber_python/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/cosy_tts/manifest.json b/agents/ten_packages/extension/cosy_tts/manifest.json index 4b1c55bc..24ac6b09 100644 --- a/agents/ten_packages/extension/cosy_tts/manifest.json +++ b/agents/ten_packages/extension/cosy_tts/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/elevenlabs_tts/manifest.json b/agents/ten_packages/extension/elevenlabs_tts/manifest.json index 25fa61e3..b3fcc92f 100644 --- a/agents/ten_packages/extension/elevenlabs_tts/manifest.json +++ b/agents/ten_packages/extension/elevenlabs_tts/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_go", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/elevenlabs_tts_python/manifest.json b/agents/ten_packages/extension/elevenlabs_tts_python/manifest.json index 48a956b1..7c0cc442 100644 --- a/agents/ten_packages/extension/elevenlabs_tts_python/manifest.json +++ b/agents/ten_packages/extension/elevenlabs_tts_python/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/file_chunker/manifest.json b/agents/ten_packages/extension/file_chunker/manifest.json index 02e91d2f..1416c357 100644 --- a/agents/ten_packages/extension/file_chunker/manifest.json +++ b/agents/ten_packages/extension/file_chunker/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/gemini_llm_python/manifest.json b/agents/ten_packages/extension/gemini_llm_python/manifest.json index d652b105..10f8f6ad 100644 --- a/agents/ten_packages/extension/gemini_llm_python/manifest.json +++ b/agents/ten_packages/extension/gemini_llm_python/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/http_server_python/manifest.json b/agents/ten_packages/extension/http_server_python/manifest.json index 05f62097..e770edda 100644 --- a/agents/ten_packages/extension/http_server_python/manifest.json +++ b/agents/ten_packages/extension/http_server_python/manifest.json @@ -1,12 +1,12 @@ { "type": "extension", "name": "http_server_python", - "version": "0.4.0", + "version": "0.5.0", "dependencies": [ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "package": { diff --git a/agents/ten_packages/extension/interrupt_detector/manifest.json b/agents/ten_packages/extension/interrupt_detector/manifest.json index 7e4beda6..feb17b2c 100644 --- a/agents/ten_packages/extension/interrupt_detector/manifest.json +++ b/agents/ten_packages/extension/interrupt_detector/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_go", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/interrupt_detector_python/manifest.json b/agents/ten_packages/extension/interrupt_detector_python/manifest.json index bf872358..97e540ad 100644 --- a/agents/ten_packages/extension/interrupt_detector_python/manifest.json +++ b/agents/ten_packages/extension/interrupt_detector_python/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/llama_index_chat_engine/manifest.json b/agents/ten_packages/extension/llama_index_chat_engine/manifest.json index 0feacf83..622d24fa 100644 --- a/agents/ten_packages/extension/llama_index_chat_engine/manifest.json +++ b/agents/ten_packages/extension/llama_index_chat_engine/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/message_collector/manifest.json b/agents/ten_packages/extension/message_collector/manifest.json index c4004695..655a37a3 100644 --- a/agents/ten_packages/extension/message_collector/manifest.json +++ b/agents/ten_packages/extension/message_collector/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1.0" + "version": "0.2" } ], "package": { diff --git a/agents/ten_packages/extension/message_collector/src/extension.py b/agents/ten_packages/extension/message_collector/src/extension.py index 39206a13..7013c432 100644 --- a/agents/ten_packages/extension/message_collector/src/extension.py +++ b/agents/ten_packages/extension/message_collector/src/extension.py @@ -7,6 +7,7 @@ # import json import time +import uuid from ten import ( AudioFrame, VideoFrame, @@ -19,7 +20,8 @@ ) from .log import logger - +MAX_SIZE = 800 # 1 KB limit +OVERHEAD_ESTIMATE = 200 # Estimate for the overhead of metadata in the JSON CMD_NAME_FLUSH = "flush" @@ -89,16 +91,12 @@ def on_data(self, ten_env: TenEnv, data: Data) -> None: try: final = data.get_property_bool(TEXT_DATA_FINAL_FIELD) except Exception as e: - logger.warning( - f"on_data get_property_bool {TEXT_DATA_FINAL_FIELD} error: {e}" - ) + pass try: stream_id = data.get_property_int(TEXT_DATA_STREAM_ID_FIELD) except Exception as e: - logger.warning( - f"on_data get_property_int {TEXT_DATA_STREAM_ID_FIELD} error: {e}" - ) + pass try: end_of_segment = data.get_property_bool(TEXT_DATA_END_OF_SEGMENT_FIELD) @@ -124,19 +122,72 @@ def on_data(self, ten_env: TenEnv, data: Data) -> None: cached_text_map[stream_id] = text - msg_data = json.dumps({ - "text": text, + # Generate a unique message ID for this batch of parts + message_id = str(uuid.uuid4()) + + # Prepare the main JSON structure without the text field + base_msg_data = { "is_final": end_of_segment, "stream_id": stream_id, + "message_id": message_id, # Add message_id to identify the split message "data_type": "transcribe", "text_ts": int(time.time() * 1000), # Convert to milliseconds - }) + } try: - # convert the origin text data to the protobuf data and send it to the graph. - ten_data = Data.create("data") - ten_data.set_property_buf("data", msg_data.encode()) - ten_env.send_data(ten_data) + # Convert the text to UTF-8 bytes + text_bytes = text.encode('utf-8') + + # If the text + metadata fits within the size limit, send it directly + if len(text_bytes) + OVERHEAD_ESTIMATE <= MAX_SIZE: + base_msg_data["text"] = text + msg_data = json.dumps(base_msg_data) + ten_data = Data.create("data") + ten_data.set_property_buf("data", msg_data.encode()) + ten_env.send_data(ten_data) + else: + # Split the text bytes into smaller chunks, ensuring safe UTF-8 splitting + max_text_size = MAX_SIZE - OVERHEAD_ESTIMATE + total_length = len(text_bytes) + total_parts = (total_length + max_text_size - 1) // max_text_size # Calculate number of parts + + def get_valid_utf8_chunk(start, end): + """Helper function to ensure valid UTF-8 chunks.""" + while end > start: + try: + # Decode to check if this chunk is valid UTF-8 + text_part = text_bytes[start:end].decode('utf-8') + return text_part, end + except UnicodeDecodeError: + # Reduce the end point to avoid splitting in the middle of a character + end -= 1 + # If no valid chunk is found (shouldn't happen with valid UTF-8 input), return an empty string + return "", start + + part_number = 0 + start_index = 0 + while start_index < total_length: + part_number += 1 + # Get a valid UTF-8 chunk + text_part, end_index = get_valid_utf8_chunk(start_index, min(start_index + max_text_size, total_length)) + + # Prepare the part data with metadata + part_data = base_msg_data.copy() + part_data.update({ + "text": text_part, + "part_number": part_number, + "total_parts": total_parts, + }) + + # Send each part + part_msg_data = json.dumps(part_data) + ten_data = Data.create("data") + ten_data.set_property_buf("data", part_msg_data.encode()) + ten_env.send_data(ten_data) + + # Move to the next chunk + start_index = end_index + except Exception as e: logger.warning(f"on_data new_data error: {e}") return diff --git a/agents/ten_packages/extension/openai_chatgpt/manifest.json b/agents/ten_packages/extension/openai_chatgpt/manifest.json index 4bc6783e..a0f97290 100644 --- a/agents/ten_packages/extension/openai_chatgpt/manifest.json +++ b/agents/ten_packages/extension/openai_chatgpt/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_go", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/openai_chatgpt_python/BUILD.gn b/agents/ten_packages/extension/openai_chatgpt_python/BUILD.gn new file mode 100644 index 00000000..23f06108 --- /dev/null +++ b/agents/ten_packages/extension/openai_chatgpt_python/BUILD.gn @@ -0,0 +1,21 @@ +# +# +# Agora Real Time Engagement +# Created by Wei Hu in 2022-11. +# Copyright (c) 2024 Agora IO. All rights reserved. +# +# +import("//build/feature/ten_package.gni") + +ten_package("openai_chatgpt_python") { + package_kind = "extension" + + resources = [ + "__init__.py", + "addon.py", + "extension.py", + "log.py", + "manifest.json", + "property.json", + ] +} diff --git a/agents/ten_packages/extension/openai_chatgpt_python/README.md b/agents/ten_packages/extension/openai_chatgpt_python/README.md new file mode 100644 index 00000000..2a9b1c82 --- /dev/null +++ b/agents/ten_packages/extension/openai_chatgpt_python/README.md @@ -0,0 +1,60 @@ +# openai_chatgpt_python + +An extension for integrating OpenAI's GPT models (e.g., GPT-4) into your application, providing configurable AI-driven features such as conversational agents, task automation, and tool integration. + +## Features + + + +- OpenAI GPT Integration: Leverage GPT models for text processing and conversational tasks. +- Configurable: Easily customize API keys, model settings, prompts, temperature, etc. +- Async Queue Processing: Supports real-time message processing with task cancellation and prioritization. +- Tool Support: Integrate external tools like image recognition via OpenAI's API. + +## API + +Refer to `api` definition in [manifest.json] and default values in [property.json](property.json). + + + +| **Property** | **Type** | **Description** | +|----------------------------|------------|-------------------------------------------| +| `api_key` | `string` | API key for authenticating with OpenAI | +| `frequency_penalty` | `float64` | Controls how much to penalize new tokens based on their existing frequency in the text so far | +| `presence_penalty` | `float64` | Controls how much to penalize new tokens based on whether they appear in the text so far | +| `temperature` | `float64` | Sampling temperature, higher values mean more randomness | +| `top_p` | `float64` | Nucleus sampling, chooses tokens with cumulative probability `p` | +| `model` | `string` | Model identifier (e.g., GPT-3.5, GPT-4) | +| `max_tokens` | `int64` | Maximum number of tokens to generate | +| `base_url` | `string` | API base URL | +| `prompt` | `string` | Default prompt to send to the model | +| `greeting` | `string` | Greeting message to be used | +| `checking_vision_text_items`| `string` | Items for checking vision-based text responses | +| `proxy_url` | `string` | URL of the proxy server | +| `max_memory_length` | `int64` | Maximum memory length for processing | +| `enable_tools` | `bool` | Flag to enable or disable external tools | + +### Data In: +| **Name** | **Property** | **Type** | **Description** | +|----------------|--------------|------------|-------------------------------| +| `text_data` | `text` | `string` | Incoming text data | + +### Data Out: +| **Name** | **Property** | **Type** | **Description** | +|----------------|--------------|------------|-------------------------------| +| `text_data` | `text` | `string` | Outgoing text data | + +### Command In: +| **Name** | **Description** | +|----------------|---------------------------------------------| +| `flush` | Command to flush the current processing state | + +### Command Out: +| **Name** | **Description** | +|----------------|---------------------------------------------| +| `flush` | Response after flushing the current state | + +### Video Frame In: +| **Name** | **Description** | +|------------------|-------------------------------------------| +| `video_frame` | Video frame input for vision processing | diff --git a/agents/ten_packages/extension/openai_chatgpt_python/__init__.py b/agents/ten_packages/extension/openai_chatgpt_python/__init__.py index 42c4cd12..09a409ff 100644 --- a/agents/ten_packages/extension/openai_chatgpt_python/__init__.py +++ b/agents/ten_packages/extension/openai_chatgpt_python/__init__.py @@ -1,4 +1,11 @@ -from . import openai_chatgpt_addon +# +# +# Agora Real Time Engagement +# Created by Wei Hu in 2024-08. +# Copyright (c) 2024 Agora IO. All rights reserved. +# +# +from . import addon from .log import logger logger.info("openai_chatgpt_python extension loaded") diff --git a/agents/ten_packages/extension/openai_chatgpt_python/addon.py b/agents/ten_packages/extension/openai_chatgpt_python/addon.py new file mode 100644 index 00000000..ee13b156 --- /dev/null +++ b/agents/ten_packages/extension/openai_chatgpt_python/addon.py @@ -0,0 +1,22 @@ +# +# +# Agora Real Time Engagement +# Created by Wei Hu in 2024-08. +# Copyright (c) 2024 Agora IO. All rights reserved. +# +# +from ten import ( + Addon, + register_addon_as_extension, + TenEnv, +) +from .extension import OpenAIChatGPTExtension +from .log import logger + + +@register_addon_as_extension("openai_chatgpt_python") +class OpenAIChatGPTExtensionAddon(Addon): + + def on_create_instance(self, ten_env: TenEnv, name: str, context) -> None: + logger.info("OpenAIChatGPTExtensionAddon on_create_instance") + ten_env.on_create_instance_done(OpenAIChatGPTExtension(name), context) diff --git a/agents/ten_packages/extension/openai_chatgpt_python/extension.py b/agents/ten_packages/extension/openai_chatgpt_python/extension.py new file mode 100644 index 00000000..5d027267 --- /dev/null +++ b/agents/ten_packages/extension/openai_chatgpt_python/extension.py @@ -0,0 +1,318 @@ +# +# +# Agora Real Time Engagement +# Created by Wei Hu in 2024-08. +# Copyright (c) 2024 Agora IO. All rights reserved. +# +# +import asyncio +import json +import random +import threading +import traceback + +from .helper import AsyncEventEmitter, AsyncQueue, get_current_time, get_property_bool, get_property_float, get_property_int, get_property_string, parse_sentences, rgb2base64jpeg +from .openai import OpenAIChatGPT, OpenAIChatGPTConfig +from ten import ( + AudioFrame, + VideoFrame, + Extension, + TenEnv, + Cmd, + StatusCode, + CmdResult, + Data, +) +from .log import logger + +CMD_IN_FLUSH = "flush" +CMD_OUT_FLUSH = "flush" +DATA_IN_TEXT_DATA_PROPERTY_TEXT = "text" +DATA_IN_TEXT_DATA_PROPERTY_IS_FINAL = "is_final" +DATA_OUT_TEXT_DATA_PROPERTY_TEXT = "text" +DATA_OUT_TEXT_DATA_PROPERTY_TEXT_END_OF_SEGMENT = "end_of_segment" + +PROPERTY_BASE_URL = "base_url" # Optional +PROPERTY_API_KEY = "api_key" # Required +PROPERTY_MODEL = "model" # Optional +PROPERTY_PROMPT = "prompt" # Optional +PROPERTY_FREQUENCY_PENALTY = "frequency_penalty" # Optional +PROPERTY_PRESENCE_PENALTY = "presence_penalty" # Optional +PROPERTY_TEMPERATURE = "temperature" # Optional +PROPERTY_TOP_P = "top_p" # Optional +PROPERTY_MAX_TOKENS = "max_tokens" # Optional +PROPERTY_GREETING = "greeting" # Optional +PROPERTY_ENABLE_TOOLS = "enable_tools" # Optional +PROPERTY_PROXY_URL = "proxy_url" # Optional +PROPERTY_MAX_MEMORY_LENGTH = "max_memory_length" # Optional +PROPERTY_CHECKING_VISION_TEXT_ITEMS = "checking_vision_text_items" # Optional + + +TASK_TYPE_CHAT_COMPLETION = "chat_completion" +TASK_TYPE_CHAT_COMPLETION_WITH_VISION = "chat_completion_with_vision" + +class OpenAIChatGPTExtension(Extension): + memory = [] + max_memory_length = 10 + openai_chatgpt = None + enable_tools = False + image_data = None + image_width = 0 + image_height = 0 + checking_vision_text_items = [] + loop = None + sentence_fragment = "" + + # Create the queue for message processing + queue = AsyncQueue() + + available_tools = [ + { + "type": "function", + "function": { + # ensure you use gpt-4o or later model if you need image recognition, gpt-4o-mini does not work quite well in this case + "name": "get_vision_image", + "description": "Get the image from camera. Call this whenever you need to understand the input camera image like you have vision capability, for example when user asks 'What can you see?' or 'Can you see me?'", + }, + "strict": True, + } + ] + + def on_init(self, ten_env: TenEnv) -> None: + logger.info("on_init") + ten_env.on_init_done() + + def on_start(self, ten_env: TenEnv) -> None: + logger.info("on_start") + + self.loop = asyncio.new_event_loop() + def start_loop(): + asyncio.set_event_loop(self.loop) + self.loop.run_forever() + threading.Thread(target=start_loop, args=[]).start() + + self.loop.create_task(self._process_queue(ten_env)) + + # Prepare configuration + openai_chatgpt_config = OpenAIChatGPTConfig.default_config() + + # Mandatory properties + openai_chatgpt_config.base_url = get_property_string(ten_env, PROPERTY_BASE_URL) or openai_chatgpt_config.base_url + openai_chatgpt_config.api_key = get_property_string(ten_env, PROPERTY_API_KEY) + if not openai_chatgpt_config.api_key: + logger.info(f"API key is missing, exiting on_start") + return + + # Optional properties + openai_chatgpt_config.model = get_property_string(ten_env, PROPERTY_MODEL) or openai_chatgpt_config.model + openai_chatgpt_config.prompt = get_property_string(ten_env, PROPERTY_PROMPT) or openai_chatgpt_config.prompt + openai_chatgpt_config.frequency_penalty = get_property_float(ten_env, PROPERTY_FREQUENCY_PENALTY) or openai_chatgpt_config.frequency_penalty + openai_chatgpt_config.presence_penalty = get_property_float(ten_env, PROPERTY_PRESENCE_PENALTY) or openai_chatgpt_config.presence_penalty + openai_chatgpt_config.temperature = get_property_float(ten_env, PROPERTY_TEMPERATURE) or openai_chatgpt_config.temperature + openai_chatgpt_config.top_p = get_property_float(ten_env, PROPERTY_TOP_P) or openai_chatgpt_config.top_p + openai_chatgpt_config.max_tokens = get_property_int(ten_env, PROPERTY_MAX_TOKENS) or openai_chatgpt_config.max_tokens + openai_chatgpt_config.proxy_url = get_property_string(ten_env, PROPERTY_PROXY_URL) or openai_chatgpt_config.proxy_url + + # Properties that don't affect openai_chatgpt_config + greeting = get_property_string(ten_env, PROPERTY_GREETING) + self.enable_tools = get_property_bool(ten_env, PROPERTY_ENABLE_TOOLS) + self.max_memory_length = get_property_int(ten_env, PROPERTY_MAX_MEMORY_LENGTH) + checking_vision_text_items_str = get_property_string(ten_env, PROPERTY_CHECKING_VISION_TEXT_ITEMS) + if checking_vision_text_items_str: + try: + self.checking_vision_text_items = json.loads(checking_vision_text_items_str) + except Exception as err: + logger.info(f"Error parsing {PROPERTY_CHECKING_VISION_TEXT_ITEMS}: {err}") + + # Create instance + try: + self.openai_chatgpt = OpenAIChatGPT(openai_chatgpt_config) + logger.info(f"initialized with max_tokens: {openai_chatgpt_config.max_tokens}, model: {openai_chatgpt_config.model}") + except Exception as err: + logger.info(f"Failed to initialize OpenAIChatGPT: {err}") + + # Send greeting if available + if greeting: + try: + output_data = Data.create("text_data") + output_data.set_property_string(DATA_OUT_TEXT_DATA_PROPERTY_TEXT, greeting) + output_data.set_property_bool(DATA_OUT_TEXT_DATA_PROPERTY_TEXT_END_OF_SEGMENT, True) + ten_env.send_data(output_data) + logger.info(f"Greeting [{greeting}] sent") + except Exception as err: + logger.info(f"Failed to send greeting [{greeting}]: {err}") + ten_env.on_start_done() + + def on_stop(self, ten_env: TenEnv) -> None: + logger.info("on_stop") + + # TODO: clean up resources + + ten_env.on_stop_done() + + def on_deinit(self, ten_env: TenEnv) -> None: + logger.info("on_deinit") + ten_env.on_deinit_done() + + def on_cmd(self, ten_env: TenEnv, cmd: Cmd) -> None: + logger.info(f"on_cmd json: {cmd.to_json()}") + + cmd_name = cmd.get_name() + + if cmd_name == CMD_IN_FLUSH: + asyncio.run_coroutine_threadsafe(self._flush_queue(), self.loop) + ten_env.send_cmd(Cmd.create(CMD_OUT_FLUSH), None) + logger.info("on_cmd sent flush") + status_code, detail = StatusCode.OK, "success" + else: + logger.info(f"on_cmd unknown cmd: {cmd_name}") + status_code, detail = StatusCode.ERROR, "unknown cmd" + + cmd_result = CmdResult.create(status_code) + cmd_result.set_property_string("detail", detail) + ten_env.return_result(cmd_result, cmd) + + def on_data(self, ten_env: TenEnv, data: Data) -> None: + # Get the necessary properties + is_final = get_property_bool(data, DATA_IN_TEXT_DATA_PROPERTY_IS_FINAL) + input_text = get_property_string(data, DATA_IN_TEXT_DATA_PROPERTY_TEXT) + + if not is_final: + logger.info("ignore non-final input") + return + if not input_text: + logger.info("ignore empty text") + return + + logger.info(f"OnData input text: [{input_text}]") + + # Start an asynchronous task for handling chat completion + asyncio.run_coroutine_threadsafe(self.queue.put([TASK_TYPE_CHAT_COMPLETION, input_text]), self.loop) + + def on_audio_frame(self, ten_env: TenEnv, audio_frame: AudioFrame) -> None: + # TODO: process pcm frame + pass + + def on_video_frame(self, ten_env: TenEnv, video_frame: VideoFrame) -> None: + # logger.info(f"OpenAIChatGPTExtension on_video_frame {frame.get_width()} {frame.get_height()}") + self.image_data = video_frame.get_buf() + self.image_width = video_frame.get_width() + self.image_height = video_frame.get_height() + return + + async def _process_queue(self, ten_env: TenEnv): + """Asynchronously process queue items one by one.""" + while True: + # Wait for an item to be available in the queue + [task_type, message] = await self.queue.get() + try: + # Create a new task for the new message + self.current_task = asyncio.create_task(self._run_chatflow(ten_env, task_type, message, self.memory)) + await self.current_task # Wait for the current task to finish or be cancelled + except asyncio.CancelledError: + logger.info(f"Task cancelled: {message}") + + async def _flush_queue(self): + """Flushes the self.queue and cancels the current task.""" + # Flush the queue using the new flush method + await self.queue.flush() + + # Cancel the current task if one is running + if self.current_task: + logger.info("Cancelling the current task during flush.") + self.current_task.cancel() + + async def _run_chatflow(self, ten_env: TenEnv, task_type:str, input_text: str, memory): + """Run the chatflow asynchronously.""" + memory_cache = [] + try: + logger.info(f"for input text: [{input_text}] memory: {memory}") + message = None + tools = None + + # Prepare the message and tools based on the task type + if task_type == TASK_TYPE_CHAT_COMPLETION: + message = {"role": "user", "content": input_text} + memory_cache = memory_cache + [message, {"role": "assistant", "content": ""}] + tools = self.available_tools if self.enable_tools else None + elif task_type == TASK_TYPE_CHAT_COMPLETION_WITH_VISION: + message = {"role": "user", "content": input_text} + memory_cache = memory_cache + [message, {"role": "assistant", "content": ""}] + tools = self.available_tools if self.enable_tools else None + if self.image_data is not None: + url = rgb2base64jpeg(self.image_data, self.image_width, self.image_height) + message = { + "role": "user", + "content": [ + {"type": "text", "text": input_text}, + {"type": "image_url", "image_url": {"url": url}}, + ], + } + logger.info(f"msg with vision data: {message}") + + + self.sentence_fragment = "" + + # Create an asyncio.Event to signal when content is finished + content_finished_event = asyncio.Event() + + # Create an async listener to handle tool calls and content updates + async def handle_tool_call(tool_call): + logger.info(f"tool_call: {tool_call}") + if tool_call.function.name == "get_vision_image": + # Append the vision image to the last assistant message + await self.queue.put([TASK_TYPE_CHAT_COMPLETION_WITH_VISION, input_text], True) + + async def handle_content_update(content:str): + # Append the content to the last assistant message + for item in reversed(memory_cache): + if item.get('role') == 'assistant': + item['content'] = item['content'] + content + break + sentences, self.sentence_fragment = parse_sentences(self.sentence_fragment, content) + for s in sentences: + self._send_data(ten_env, s, False) + + async def handle_content_finished(full_content:str): + content_finished_event.set() + + listener = AsyncEventEmitter() + listener.on("tool_call", handle_tool_call) + listener.on("content_update", handle_content_update) + listener.on("content_finished", handle_content_finished) + + # Make an async API call to get chat completions + await self.openai_chatgpt.get_chat_completions_stream(memory + [message], tools, listener) + + # Wait for the content to be finished + await content_finished_event.wait() + except asyncio.CancelledError: + logger.info(f"Task cancelled: {input_text}") + except Exception as e: + logger.error(f"Error in chat_completion: {traceback.format_exc()} for input text: {input_text}") + finally: + self._send_data(ten_env, "", True) + # always append the memory + for m in memory_cache: + self._append_memory(m) + + def _append_memory(self, message:str): + if len(self.memory) > self.max_memory_length: + self.memory.pop(0) + self.memory.append(message) + + def _send_data(self, ten_env: TenEnv, sentence: str, end_of_segment: bool): + try: + output_data = Data.create("text_data") + output_data.set_property_string(DATA_OUT_TEXT_DATA_PROPERTY_TEXT, sentence) + output_data.set_property_bool( + DATA_OUT_TEXT_DATA_PROPERTY_TEXT_END_OF_SEGMENT, end_of_segment + ) + ten_env.send_data(output_data) + logger.info( + f"{'end of segment ' if end_of_segment else ''}sent sentence [{sentence}]" + ) + except Exception as err: + logger.info( + f"send sentence [{sentence}] failed, err: {err}" + ) \ No newline at end of file diff --git a/agents/ten_packages/extension/openai_chatgpt_python/helper.py b/agents/ten_packages/extension/openai_chatgpt_python/helper.py new file mode 100644 index 00000000..28c28f19 --- /dev/null +++ b/agents/ten_packages/extension/openai_chatgpt_python/helper.py @@ -0,0 +1,187 @@ +# +# +# Agora Real Time Engagement +# Created by Wei Hu in 2024-08. +# Copyright (c) 2024 Agora IO. All rights reserved. +# +# +import asyncio +from collections import deque +from ten.data import Data +from .log import logger +from PIL import Image +from datetime import datetime +from io import BytesIO +from base64 import b64encode + + +def get_property_bool(data: Data, property_name: str) -> bool: + """Helper to get boolean property from data with error handling.""" + try: + return data.get_property_bool(property_name) + except Exception as err: + logger.warn(f"GetProperty {property_name} failed: {err}") + return False + +def get_property_string(data: Data, property_name: str) -> str: + """Helper to get string property from data with error handling.""" + try: + return data.get_property_string(property_name) + except Exception as err: + logger.warn(f"GetProperty {property_name} failed: {err}") + return "" + +def get_property_int(data: Data, property_name: str) -> int: + """Helper to get int property from data with error handling.""" + try: + return data.get_property_int(property_name) + except Exception as err: + logger.warn(f"GetProperty {property_name} failed: {err}") + return 0 + +def get_property_float(data: Data, property_name: str) -> float: + """Helper to get float property from data with error handling.""" + try: + return data.get_property_float(property_name) + except Exception as err: + logger.warn(f"GetProperty {property_name} failed: {err}") + return 0.0 + + +def get_current_time(): + # Get the current time + start_time = datetime.now() + # Get the number of microseconds since the Unix epoch + unix_microseconds = int(start_time.timestamp() * 1_000_000) + return unix_microseconds + + +def is_punctuation(char): + if char in [",", ",", ".", "。", "?", "?", "!", "!"]: + return True + return False + + +def parse_sentences(sentence_fragment, content): + sentences = [] + current_sentence = sentence_fragment + for char in content: + current_sentence += char + if is_punctuation(char): + # Check if the current sentence contains non-punctuation characters + stripped_sentence = current_sentence + if any(c.isalnum() for c in stripped_sentence): + sentences.append(stripped_sentence) + current_sentence = "" # Reset for the next sentence + + remain = current_sentence # Any remaining characters form the incomplete sentence + return sentences, remain + + + +def rgb2base64jpeg(rgb_data, width, height): + # Convert the RGB image to a PIL Image + pil_image = Image.frombytes("RGBA", (width, height), bytes(rgb_data)) + pil_image = pil_image.convert("RGB") + + # Resize the image while maintaining its aspect ratio + pil_image = resize_image_keep_aspect(pil_image, 320) + + # Save the image to a BytesIO object in JPEG format + buffered = BytesIO() + pil_image.save(buffered, format="JPEG") + # pil_image.save("test.jpg", format="JPEG") + + # Get the byte data of the JPEG image + jpeg_image_data = buffered.getvalue() + + # Convert the JPEG byte data to a Base64 encoded string + base64_encoded_image = b64encode(jpeg_image_data).decode("utf-8") + + # Create the data URL + mime_type = "image/jpeg" + base64_url = f"data:{mime_type};base64,{base64_encoded_image}" + return base64_url + + +def resize_image_keep_aspect(image, max_size=512): + """ + Resize an image while maintaining its aspect ratio, ensuring the larger dimension is max_size. + If both dimensions are smaller than max_size, the image is not resized. + + :param image: A PIL Image object + :param max_size: The maximum size for the larger dimension (width or height) + :return: A PIL Image object (resized or original) + """ + # Get current width and height + width, height = image.size + + # If both dimensions are already smaller than max_size, return the original image + if width <= max_size and height <= max_size: + return image + + # Calculate the aspect ratio + aspect_ratio = width / height + + # Determine the new dimensions + if width > height: + new_width = max_size + new_height = int(max_size / aspect_ratio) + else: + new_height = max_size + new_width = int(max_size * aspect_ratio) + + # Resize the image with the new dimensions + resized_image = image.resize((new_width, new_height)) + + return resized_image + + +class AsyncEventEmitter: + def __init__(self): + self.listeners = {} + + def on(self, event_name, listener): + """Register an event listener.""" + if event_name not in self.listeners: + self.listeners[event_name] = [] + self.listeners[event_name].append(listener) + + def emit(self, event_name, *args, **kwargs): + """Fire the event without waiting for listeners to finish.""" + if event_name in self.listeners: + for listener in self.listeners[event_name]: + asyncio.create_task(listener(*args, **kwargs)) + + +class AsyncQueue: + def __init__(self): + self._queue = deque() # Use deque for efficient prepend and append + self._condition = asyncio.Condition() # Use Condition to manage access + + async def put(self, item, prepend=False): + """Add an item to the queue (prepend if specified).""" + async with self._condition: + if prepend: + self._queue.appendleft(item) # Prepend item to the front + else: + self._queue.append(item) # Append item to the back + self._condition.notify() + + async def get(self): + """Remove and return an item from the queue.""" + async with self._condition: + while not self._queue: + await self._condition.wait() # Wait until an item is available + return self._queue.popleft() # Pop from the front of the deque + + async def flush(self): + """Flush all items from the queue.""" + async with self._condition: + while self._queue: + self._queue.popleft() # Clear the queue + self._condition.notify_all() # Notify all consumers that the queue is empty + + def __len__(self): + """Return the current size of the queue.""" + return len(self._queue) \ No newline at end of file diff --git a/agents/ten_packages/extension/openai_chatgpt_python/log.py b/agents/ten_packages/extension/openai_chatgpt_python/log.py index fa2202da..1813e965 100644 --- a/agents/ten_packages/extension/openai_chatgpt_python/log.py +++ b/agents/ten_packages/extension/openai_chatgpt_python/log.py @@ -1,11 +1,20 @@ +# +# +# Agora Real Time Engagement +# Created by Wei Hu in 2024-08. +# Copyright (c) 2024 Agora IO. All rights reserved. +# +# import logging logger = logging.getLogger("openai_chatgpt_python") logger.setLevel(logging.INFO) -formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(process)d - [%(filename)s:%(lineno)d] - %(message)s" +formatter_str = ( + "%(asctime)s - %(name)s - %(levelname)s - %(process)d - " + "[%(filename)s:%(lineno)d] - %(message)s" ) +formatter = logging.Formatter(formatter_str) console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) diff --git a/agents/ten_packages/extension/openai_chatgpt_python/manifest.json b/agents/ten_packages/extension/openai_chatgpt_python/manifest.json index b23cb9d8..4a74e306 100644 --- a/agents/ten_packages/extension/openai_chatgpt_python/manifest.json +++ b/agents/ten_packages/extension/openai_chatgpt_python/manifest.json @@ -1,14 +1,24 @@ { "type": "extension", "name": "openai_chatgpt_python", - "version": "0.4.0", + "version": "0.1.0", "dependencies": [ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], + "package": { + "include": [ + "manifest.json", + "property.json", + "BUILD.gn", + "**.tent", + "**.py", + "README.md" + ] + }, "api": { "property": { "api_key": { diff --git a/agents/ten_packages/extension/openai_chatgpt_python/openai.py b/agents/ten_packages/extension/openai_chatgpt_python/openai.py new file mode 100644 index 00000000..3449126d --- /dev/null +++ b/agents/ten_packages/extension/openai_chatgpt_python/openai.py @@ -0,0 +1,125 @@ +# +# +# Agora Real Time Engagement +# Created by Wei Hu in 2024-08. +# Copyright (c) 2024 Agora IO. All rights reserved. +# +# +import random +import requests +from openai import AsyncOpenAI +from typing import List, Dict, Any, Optional +from .log import logger + + +class OpenAIChatGPTConfig: + def __init__(self, + base_url: str, + api_key: str, + model: str, + prompt: str, + frequency_penalty: float, + presence_penalty: float, + top_p: float, + temperature: float, + max_tokens: int, + seed: Optional[int] = None, + proxy_url: Optional[str] = None): + self.base_url = base_url + self.api_key = api_key + self.model = model + self.prompt = prompt + self.frequency_penalty = frequency_penalty + self.presence_penalty = presence_penalty + self.top_p = top_p + self.temperature = temperature + self.max_tokens = max_tokens + self.seed = seed if seed is not None else random.randint(0, 10000) + self.proxy_url = proxy_url + + @classmethod + def default_config(cls): + return cls( + base_url="https://api.openai.com/v1", + api_key="", + model="gpt-4", # Adjust this to match the equivalent of `openai.GPT4o` in the Python library + prompt="You are a voice assistant who talks in a conversational way and can chat with me like my friends. I will speak to you in English or Chinese, and you will answer in the corrected and improved version of my text with the language I use. Don’t talk like a robot, instead I would like you to talk like a real human with emotions. I will use your answer for text-to-speech, so don’t return me any meaningless characters. I want you to be helpful, when I’m asking you for advice, give me precise, practical and useful advice instead of being vague. When giving me a list of options, express the options in a narrative way instead of bullet points.", + frequency_penalty=0.9, + presence_penalty=0.9, + top_p=1.0, + temperature=0.1, + max_tokens=512, + seed=random.randint(0, 10000), + proxy_url="" + ) + + +class OpenAIChatGPT: + client = None + def __init__(self, config: OpenAIChatGPTConfig): + self.config = config + logger.info(f"OpenAIChatGPT initialized with config: {config.api_key}") + self.client = AsyncOpenAI( + api_key=config.api_key, + base_url=config.base_url + ) + self.session = requests.Session() + if config.proxy_url: + proxies = { + "http": config.proxy_url, + "https": config.proxy_url, + } + self.session.proxies.update(proxies) + self.client.session = self.session + + async def get_chat_completions_stream(self, messages, tools = None, listener = None): + req = { + "model": self.config.model, + "messages": [ + { + "role": "system", + "content": self.config.prompt, + }, + *messages, + ], + "tools": tools, + "temperature": self.config.temperature, + "top_p": self.config.top_p, + "presence_penalty": self.config.presence_penalty, + "frequency_penalty": self.config.frequency_penalty, + "max_tokens": self.config.max_tokens, + "seed": self.config.seed, + "stream": True, + } + + try: + response = await self.client.chat.completions.create(**req) + except Exception as e: + raise Exception(f"CreateChatCompletionStream failed, err: {e}") + + full_content = "" + + async for chat_completion in response: + choice = chat_completion.choices[0] + delta = choice.delta + + content = delta.content if delta and delta.content else "" + + # Emit content update event (fire-and-forget) + if listener and content: + listener.emit('content_update', content) + + full_content += content + + # Check for tool calls + if delta.tool_calls: + for tool_call in delta.tool_calls: + logger.info(f"tool_call: {tool_call}") + + # Emit tool call event (fire-and-forget) + if listener: + listener.emit('tool_call', tool_call) + + # Emit content finished event after the loop completes + if listener: + listener.emit('content_finished', full_content) diff --git a/agents/ten_packages/extension/openai_chatgpt_python/requirements.txt b/agents/ten_packages/extension/openai_chatgpt_python/requirements.txt index ca4978c3..51cdd053 100644 --- a/agents/ten_packages/extension/openai_chatgpt_python/requirements.txt +++ b/agents/ten_packages/extension/openai_chatgpt_python/requirements.txt @@ -1,4 +1,4 @@ openai numpy -requests==2.32.3 -pillow==10.4.0 \ No newline at end of file +requests +pillow \ No newline at end of file diff --git a/agents/ten_packages/extension/polly_tts/manifest.json b/agents/ten_packages/extension/polly_tts/manifest.json index dfbc94ea..932c3b47 100644 --- a/agents/ten_packages/extension/polly_tts/manifest.json +++ b/agents/ten_packages/extension/polly_tts/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/qwen_llm_python/manifest.json b/agents/ten_packages/extension/qwen_llm_python/manifest.json index a05290af..bdad7d87 100644 --- a/agents/ten_packages/extension/qwen_llm_python/manifest.json +++ b/agents/ten_packages/extension/qwen_llm_python/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/agents/ten_packages/extension/transcribe_asr_python/manifest.json b/agents/ten_packages/extension/transcribe_asr_python/manifest.json index 69386329..5950e3f7 100644 --- a/agents/ten_packages/extension/transcribe_asr_python/manifest.json +++ b/agents/ten_packages/extension/transcribe_asr_python/manifest.json @@ -6,7 +6,7 @@ { "type": "system", "name": "ten_runtime_python", - "version": "0.1" + "version": "0.2" } ], "api": { diff --git a/demo/.dockerignore b/demo/.dockerignore new file mode 100644 index 00000000..80ae13ce --- /dev/null +++ b/demo/.dockerignore @@ -0,0 +1,3 @@ +.git +.next +node_modules diff --git a/demo/.env b/demo/.env new file mode 100644 index 00000000..5f92b324 --- /dev/null +++ b/demo/.env @@ -0,0 +1 @@ +AGENT_SERVER_URL=http://localhost:8080 \ No newline at end of file diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 00000000..3bcf2bf5 --- /dev/null +++ b/demo/.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 +.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 +package-lock.json +yarn.lock diff --git a/demo/Dockerfile b/demo/Dockerfile new file mode 100644 index 00000000..63379d47 --- /dev/null +++ b/demo/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-alpine AS base + +FROM base AS builder + +WORKDIR /app + +# COPY .env.example .env +COPY . . + +RUN npm i --verbose && \ + npm run build + + +FROM base AS runner + +WORKDIR /app + +ENV NODE_ENV production + +RUN mkdir .next + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 + +CMD HOSTNAME="0.0.0.0" node server.js \ No newline at end of file diff --git a/demo/LICENSE b/demo/LICENSE new file mode 100644 index 00000000..e4589a2b --- /dev/null +++ b/demo/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Agora Community + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/demo/next-env.d.ts b/demo/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/demo/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/demo/next.config.mjs b/demo/next.config.mjs new file mode 100644 index 00000000..132f5fc1 --- /dev/null +++ b/demo/next.config.mjs @@ -0,0 +1,37 @@ +/** @type {import('next').NextConfig} */ + +const nextConfig = { + // basePath: '/ai-agent', + // output: 'export', + output: 'standalone', + reactStrictMode: false, + webpack(config) { + // Grab the existing rule that handles SVG imports + const fileLoaderRule = config.module.rules.find((rule) => + rule.test?.test?.('.svg'), + ) + + config.module.rules.push( + // Reapply the existing rule, but only for svg imports ending in ?url + { + ...fileLoaderRule, + test: /\.svg$/i, + resourceQuery: /url/, // *.svg?url + }, + // Convert all other *.svg imports to React components + { + test: /\.svg$/i, + issuer: fileLoaderRule.issuer, + resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url + use: ['@svgr/webpack'], + }, + ) + + // Modify the file loader rule to ignore *.svg, since we have it handled now. + fileLoaderRule.exclude = /\.svg$/i + + return config + } +}; + +export default nextConfig; diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 00000000..668d2d3f --- /dev/null +++ b/demo/package.json @@ -0,0 +1,42 @@ +{ + "name": "astra.ai-playground", + "version": "0.4.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "proto": "pbjs -t json-module -w commonjs -o src/protobuf/SttMessage.js src/protobuf/SttMessage.proto" + }, + "dependencies": { + "@ant-design/icons": "^5.3.7", + "@reduxjs/toolkit": "^2.2.3", + "agora-rtc-sdk-ng": "^4.21.0", + "antd": "^5.15.3", + "axios": "^1.7.7", + "next": "14.2.4", + "protobufjs": "^7.2.5", + "react": "^18", + "react-colorful": "^5.6.1", + "react-dom": "^18", + "react-redux": "^9.1.0", + "redux": "^5.0.1" + }, + "devDependencies": { + "@minko-fe/postcss-pxtoviewport": "^1.3.2", + "@svgr/webpack": "^8.1.0", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/react-redux": "^7.1.22", + "autoprefixer": "^10.4.16", + "eslint": "^8", + "eslint-config-next": "14.2.4", + "postcss": "^8.4.31", + "protobufjs-cli": "^1.1.2", + "sass": "^1.77.5", + "typescript": "^5" + }, + "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" +} diff --git a/demo/postcss.config.js b/demo/postcss.config.js new file mode 100644 index 00000000..ea748d4d --- /dev/null +++ b/demo/postcss.config.js @@ -0,0 +1,10 @@ +module.exports = { + plugins: { + autoprefixer: {}, + "@minko-fe/postcss-pxtoviewport": { + viewportWidth: 375, + exclude: /node_modules/, + include: /\/src\/platform\/mobile\//, + } + }, +} diff --git a/demo/src/app/api/agents/start/graph.tsx b/demo/src/app/api/agents/start/graph.tsx new file mode 100644 index 00000000..cc43137e --- /dev/null +++ b/demo/src/app/api/agents/start/graph.tsx @@ -0,0 +1,159 @@ +import { LanguageMap } from '@/common/constant'; +import { NextRequest, NextResponse } from 'next/server'; + + +const { AGENT_SERVER_URL } = process.env; + +// Check if environment variables are available +if (!AGENT_SERVER_URL) { + throw "Environment variables AGENT_SERVER_URL are not available"; +} + + +export const voiceNameMap: LanguageMap = { + "zh-CN": { + azure: { + male: "zh-CN-YunxiNeural", + female: "zh-CN-XiaoxiaoNeural", + }, + elevenlabs: { + male: "pNInz6obpgDQGcFmaJgB", // Adam + female: "Xb7hH8MSUJpSbSDYk0k2", // Alice + }, + polly: { + male: "Zhiyu", + female: "Zhiyu", + }, + }, + "en-US": { + azure: { + male: "en-US-BrianNeural", + female: "en-US-JaneNeural", + }, + elevenlabs: { + male: "pNInz6obpgDQGcFmaJgB", // Adam + female: "Xb7hH8MSUJpSbSDYk0k2", // Alice + }, + polly: { + male: "Matthew", + female: "Ruth", + }, + }, + "ja-JP": { + azure: { + male: "ja-JP-KeitaNeural", + female: "ja-JP-NanamiNeural", + }, + }, + "ko-KR": { + azure: { + male: "ko-KR-InJoonNeural", + female: "ko-KR-JiMinNeural", + }, + }, +}; + +// Get the graph properties based on the graph name, language, and voice type +// This is the place where you can customize the properties for different graphs to override default property.json +export const getGraphProperties = (graphName: string, language: string, voiceType: string) => { + let localizationOptions = { + "greeting": "TEN agent connected. How can I help you today?", + "checking_vision_text_items": "[\"Let me take a look...\",\"Let me check your camera...\",\"Please wait for a second...\"]", + } + + if (language === "zh-CN") { + localizationOptions = { + "greeting": "TEN Agent 已连接,需要我为您提供什么帮助?", + "checking_vision_text_items": "[\"让我看看你的摄像头...\",\"让我看一下...\",\"我看一下,请稍候...\"]", + } + } else if (language === "ja-JP") { + localizationOptions = { + "greeting": "TEN Agent エージェントに接続されました。今日は何をお手伝いしましょうか?", + "checking_vision_text_items": "[\"ちょっと見てみます...\",\"カメラをチェックします...\",\"少々お待ちください...\"]", + } + } else if (language === "ko-KR") { + localizationOptions = { + "greeting": "TEN Agent 에이전트에 연결되었습니다. 오늘은 무엇을 도와드릴까요?", + "checking_vision_text_items": "[\"조금만 기다려 주세요...\",\"카메라를 확인해 보겠습니다...\",\"잠시만 기다려 주세요...\"]", + } + } + + if (graphName == "camera.va.openai.azure") { + return { + "agora_rtc": { + "agora_asr_language": language, + }, + "openai_chatgpt": { + "model": "gpt-4o", + ...localizationOptions + }, + "azure_tts": { + "azure_synthesis_voice_name": voiceNameMap[language]["azure"][voiceType] + } + } + } else if (graphName == "va.openai.azure") { + return { + "agora_rtc": { + "agora_asr_language": language, + }, + "openai_chatgpt": { + "model": "gpt-4o-mini", + ...localizationOptions + }, + "azure_tts": { + "azure_synthesis_voice_name": voiceNameMap[language]["azure"][voiceType] + } + } + } else if (graphName == "va.qwen.rag") { + return { + "agora_rtc": { + "agora_asr_language": language, + }, + "azure_tts": { + "azure_synthesis_voice_name": voiceNameMap[language]["azure"][voiceType] + } + } + } + return {} +} + +export async function startAgent(request: NextRequest) { + try{ + const body = await request.json(); + const { + request_id, + channel_name, + user_uid, + graph_name, + language, + voice_type, + } = body; + + // Send a POST request to start the agent + const response = await fetch(`${AGENT_SERVER_URL}/start`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + request_id, + channel_name, + user_uid, + graph_name, + // Get the graph properties based on the graph name, language, and voice type + properties: getGraphProperties(graph_name, language, voice_type), + }), + }); + + const responseData = await response.json(); + + return NextResponse.json(responseData, { status: response.status }); + } catch (error) { + if (error instanceof Response) { + const errorData = await error.json(); + return NextResponse.json(errorData, { status: error.status }); + } else { + return NextResponse.json({ code: "1", data: null, msg: "Internal Server Error" }, { status: 500 }); + } + } +} diff --git a/demo/src/app/api/agents/start/route.tsx b/demo/src/app/api/agents/start/route.tsx new file mode 100644 index 00000000..187621a6 --- /dev/null +++ b/demo/src/app/api/agents/start/route.tsx @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getGraphProperties } from './graph'; +import axios from 'axios'; +/** + * Handles the POST request to start an agent. + * + * @param request - The NextRequest object representing the incoming request. + * @returns A NextResponse object representing the response to be sent back to the client. + */ +export async function POST(request: NextRequest) { + try { + const { AGENT_SERVER_URL } = process.env; + + // Check if environment variables are available + if (!AGENT_SERVER_URL) { + throw "Environment variables are not available"; + } + + const body = await request.json(); + const { + request_id, + channel_name, + user_uid, + graph_name, + language, + voice_type, + } = body; + + console.log(`Starting agent for request ID: ${JSON.stringify({ + request_id, + channel_name, + user_uid, + graph_name, + // Get the graph properties based on the graph name, language, and voice type + properties: getGraphProperties(graph_name, language, voice_type), + })}`); + + console.log(`AGENT_SERVER_URL: ${AGENT_SERVER_URL}/start`); + + // Send a POST request to start the agent + const response = await axios.post(`${AGENT_SERVER_URL}/start`, { + request_id, + channel_name, + user_uid, + graph_name, + // Get the graph properties based on the graph name, language, and voice type + properties: getGraphProperties(graph_name, language, voice_type), + }); + + const responseData = response.data; + + return NextResponse.json(responseData, { status: response.status }); + } catch (error) { + if (error instanceof Response) { + const errorData = await error.json(); + return NextResponse.json(errorData, { status: error.status }); + } else { + return NextResponse.json({ code: "1", data: null, msg: "Internal Server Error" }, { status: 500 }); + } + } +} \ No newline at end of file diff --git a/demo/src/app/favicon.ico b/demo/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..21b38b9698bfbc530683c5ca32c676d7afee53f1 GIT binary patch literal 15406 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 literal 0 HcmV?d00001 diff --git a/demo/src/app/global.css b/demo/src/app/global.css new file mode 100644 index 00000000..7a1e1861 --- /dev/null +++ b/demo/src/app/global.css @@ -0,0 +1,67 @@ +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + background-color: #0F0F11; + font-family: "PingFang SC"; + height: 100%; +} + +a { + color: inherit; + text-decoration: none; +} + +@media (prefers-color-scheme: dark) { + html { + color-scheme: dark; + } +} + +.ant-select-arrow { + color: #667085 !important; +} + + +.ant-select-selection-item { + color: #667085 !important; +} + +.ant-select-selector { + border: 1px solid #272A2F !important; + background-color: #272A2F !important; +} + +.ant-select-dropdown { + background-color: #1E2024 !important; +} + +.ant-select-item { + background: #1E2024 !important; + color: var(--Grey-600, #667085) !important; +} + +.ant-select-item-option-selected { + background: #272A2F !important; + color: var(--Grey-300, #EAECF0) !important; +} + + +.ant-popover-inner { + /* width: 260px !important; */ + background: #1E2025 !important; +} + + +.ant-select-selection-placeholder { + color: var(--Grey-600, #667085) !important; +} + + +.ant-empty-description { + color: var(--Grey-600, #667085) !important; +} diff --git a/playground/src/app/home/page.tsx b/demo/src/app/home/page.tsx similarity index 100% rename from playground/src/app/home/page.tsx rename to demo/src/app/home/page.tsx diff --git a/demo/src/app/index.module.scss b/demo/src/app/index.module.scss new file mode 100644 index 00000000..78585596 --- /dev/null +++ b/demo/src/app/index.module.scss @@ -0,0 +1,93 @@ +@function multiple-box-shadow($n) { + $value: '#{random(2000)}px #{random(2000)}px #FFF'; + + @for $i from 2 through $n { + $value: '#{$value}, #{random(2000)}px #{random(2000)}px #FFF'; + } + + @return unquote($value); +} + +$shadows-small: multiple-box-shadow(700); +$shadows-medium: multiple-box-shadow(200); +$shadows-big: multiple-box-shadow(100); + + +.login { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: hidden; + // background: radial-gradient(ellipse at bottom, #1B2735 0%, #090A0F 100%); + background: url("../assets/background.jpg") no-repeat center center; + background-size: cover; + box-sizing: border-box; + + .starts { + width: 1px; + height: 1px; + background: transparent; + box-shadow: $shadows-small; + animation: animStar 50s linear infinite; + + &:after { + content: " "; + position: absolute; + top: 2000px; + width: 1px; + height: 1px; + background: transparent; + box-shadow: $shadows-small + } + } + + .starts2 { + width: 2px; + height: 2px; + box-shadow: $shadows-medium; + animation: animStar 100s linear infinite; + + &:after { + content: " "; + position: absolute; + top: 2000px; + width: 2px; + height: 2px; + background: transparent; + box-shadow: $shadows-medium; + } + } + + .starts3 { + width: 3px; + height: 3px; + background: transparent; + box-shadow: $shadows-big; + animation: animStar 150s linear infinite; + + &:after { + content: " "; + position: absolute; + top: 2000px; + width: 3px; + height: 3px; + background: transparent; + box-shadow: $shadows-big; + } + + } + +} + + +@keyframes animStar { + from { + transform: translateY(0px) + } + + to { + transform: translateY(-2000px) + } +} diff --git a/demo/src/app/layout.tsx b/demo/src/app/layout.tsx new file mode 100644 index 00000000..b6153573 --- /dev/null +++ b/demo/src/app/layout.tsx @@ -0,0 +1,53 @@ +import { ConfigProvider } from "antd" +import { StoreProvider } from "@/store"; +import type { Metadata, Viewport } from "next"; + +import './global.css' + + +export const metadata: Metadata = { + title: "Astra.ai", + description: "A multimodal agent powered by TEN", + appleWebApp: { + capable: true, + statusBarStyle: "black", + } +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + minimumScale: 1, + maximumScale: 1, + userScalable: false, + viewportFit: "cover", +} + + + + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + {children} + + + + + ); +} diff --git a/demo/src/app/page.tsx b/demo/src/app/page.tsx new file mode 100644 index 00000000..1bdcdeda --- /dev/null +++ b/demo/src/app/page.tsx @@ -0,0 +1,14 @@ +import LoginCard from "@/components/loginCard" +import styles from "./index.module.scss" + +export default function Login() { + + return ( +
+
+
+
+ +
+ ); +} diff --git a/demo/src/assets/background.jpg b/demo/src/assets/background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..027623438677ced6d9c14ca92075cc8c6486158e GIT binary patch literal 95856 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�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 jgW4Gv%HDF>#`k<@6t9! + + diff --git a/demo/src/assets/cam_unmute.svg b/demo/src/assets/cam_unmute.svg new file mode 100644 index 00000000..1eebfaa6 --- /dev/null +++ b/demo/src/assets/cam_unmute.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/src/assets/color_picker.svg b/demo/src/assets/color_picker.svg new file mode 100644 index 00000000..fb9bb33e --- /dev/null +++ b/demo/src/assets/color_picker.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/demo/src/assets/github.svg b/demo/src/assets/github.svg new file mode 100644 index 00000000..e6566c41 --- /dev/null +++ b/demo/src/assets/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/src/assets/info.svg b/demo/src/assets/info.svg new file mode 100644 index 00000000..8ca99511 --- /dev/null +++ b/demo/src/assets/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/src/assets/logo.svg b/demo/src/assets/logo.svg new file mode 100644 index 00000000..af99893a --- /dev/null +++ b/demo/src/assets/logo.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/src/assets/logo_small.svg b/demo/src/assets/logo_small.svg new file mode 100644 index 00000000..34e755bd --- /dev/null +++ b/demo/src/assets/logo_small.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/src/assets/mic_mute.svg b/demo/src/assets/mic_mute.svg new file mode 100644 index 00000000..dd4a17dd --- /dev/null +++ b/demo/src/assets/mic_mute.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/src/assets/mic_unmute.svg b/demo/src/assets/mic_unmute.svg new file mode 100644 index 00000000..18e78236 --- /dev/null +++ b/demo/src/assets/mic_unmute.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/src/assets/network/average.svg b/demo/src/assets/network/average.svg new file mode 100644 index 00000000..9a27072f --- /dev/null +++ b/demo/src/assets/network/average.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/demo/src/assets/network/disconnected.svg b/demo/src/assets/network/disconnected.svg new file mode 100644 index 00000000..b7db1d71 --- /dev/null +++ b/demo/src/assets/network/disconnected.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/demo/src/assets/network/excellent.svg b/demo/src/assets/network/excellent.svg new file mode 100644 index 00000000..55b9fc9e --- /dev/null +++ b/demo/src/assets/network/excellent.svg @@ -0,0 +1,6 @@ + + + + diff --git a/demo/src/assets/network/good.svg b/demo/src/assets/network/good.svg new file mode 100644 index 00000000..8c36a7e7 --- /dev/null +++ b/demo/src/assets/network/good.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/demo/src/assets/network/poor.svg b/demo/src/assets/network/poor.svg new file mode 100644 index 00000000..d9df0238 --- /dev/null +++ b/demo/src/assets/network/poor.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/demo/src/assets/pdf.svg b/demo/src/assets/pdf.svg new file mode 100644 index 00000000..dc67f4d5 --- /dev/null +++ b/demo/src/assets/pdf.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/src/assets/transcription.svg b/demo/src/assets/transcription.svg new file mode 100644 index 00000000..8b887a6f --- /dev/null +++ b/demo/src/assets/transcription.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/demo/src/assets/voice.svg b/demo/src/assets/voice.svg new file mode 100644 index 00000000..86a880b0 --- /dev/null +++ b/demo/src/assets/voice.svg @@ -0,0 +1,3 @@ + + + diff --git a/demo/src/common/constant.ts b/demo/src/common/constant.ts new file mode 100644 index 00000000..fee0c18e --- /dev/null +++ b/demo/src/common/constant.ts @@ -0,0 +1,88 @@ +import { IOptions, ColorItem, LanguageOptionItem, VoiceOptionItem, GraphOptionItem } from "@/types" +export const GITHUB_URL = "https://github.com/TEN-framework/ASTRA.ai" +export const OPTIONS_KEY = "__options__" +export const DEFAULT_OPTIONS: IOptions = { + channel: "", + userName: "", + userId: 0 +} +export const DESCRIPTION = "This is an AI voice assistant powered by ASTRA.ai framework, Agora, Azure and ChatGPT." +export const LANGUAGE_OPTIONS: LanguageOptionItem[] = [ + { + label: "English", + value: "en-US" + }, + { + label: "Chinese", + value: "zh-CN" + }, + { + label: "Korean", + value: "ko-KR" + }, + { + label: "Japanese", + value: "ja-JP" + } +] +export const GRAPH_OPTIONS: GraphOptionItem[] = [ + { + label: "Voice Agent - OpenAI LLM + Azure TTS", + value: "va.openai.azure" + }, + { + label: "Voice Agent with Vision - OpenAI LLM + Azure TTS", + value: "camera.va.openai.azure" + }, + { + label: "Voice Agent with Knowledge - RAG + Qwen LLM + Cosy TTS", + value: "va.qwen.rag" + }, +] + +export const isRagGraph = (graphName: string) => { + return graphName === "va.qwen.rag" +} + +export const VOICE_OPTIONS: VoiceOptionItem[] = [ + { + label: "Male", + value: "male" + }, + { + label: "Female", + value: "female" + } +] +export const COLOR_LIST: ColorItem[] = [{ + active: "#0888FF", + default: "#143354" +}, { + active: "#563FD8", + default: "#2C2553" +}, +{ + active: "#18A957", + default: "#173526" +}, { + active: "#FFAB08", + default: "#423115" +}, { + active: "#FD5C63", + default: "#462629" +}, { + active: "#E225B2", + default: "#481C3F" +}] + +export type VoiceTypeMap = { + [voiceType: string]: string; +}; + +export type VendorNameMap = { + [vendorName: string]: VoiceTypeMap; +}; + +export type LanguageMap = { + [language: string]: VendorNameMap; +}; \ No newline at end of file diff --git a/demo/src/common/hooks.ts b/demo/src/common/hooks.ts new file mode 100644 index 00000000..9759fa29 --- /dev/null +++ b/demo/src/common/hooks.ts @@ -0,0 +1,131 @@ +"use client" + +import { IMicrophoneAudioTrack } from "agora-rtc-sdk-ng" +import { normalizeFrequencies } from "./utils" +import { useState, useEffect, useMemo, useRef } from "react" +import type { AppDispatch, AppStore, RootState } from "../store" +import { useDispatch, useSelector, useStore } from "react-redux" +import { Grid } from "antd" + +const { useBreakpoint } = Grid; + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector = useSelector.withTypes() +export const useAppStore = useStore.withTypes() + +export const useMultibandTrackVolume = ( + track?: IMicrophoneAudioTrack | MediaStreamTrack, + bands: number = 5, + loPass: number = 100, + hiPass: number = 600 +) => { + const [frequencyBands, setFrequencyBands] = useState([]); + + useEffect(() => { + if (!track) { + return setFrequencyBands(new Array(bands).fill(new Float32Array(0))) + } + + const ctx = new AudioContext(); + let finTrack = track instanceof MediaStreamTrack ? track : track.getMediaStreamTrack() + const mediaStream = new MediaStream([finTrack]); + const source = ctx.createMediaStreamSource(mediaStream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 2048 + + source.connect(analyser); + + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Float32Array(bufferLength); + + const updateVolume = () => { + analyser.getFloatFrequencyData(dataArray); + let frequencies: Float32Array = new Float32Array(dataArray.length); + for (let i = 0; i < dataArray.length; i++) { + frequencies[i] = dataArray[i]; + } + frequencies = frequencies.slice(loPass, hiPass); + + const normalizedFrequencies = normalizeFrequencies(frequencies); + const chunkSize = Math.ceil(normalizedFrequencies.length / bands); + const chunks: Float32Array[] = []; + for (let i = 0; i < bands; i++) { + chunks.push( + normalizedFrequencies.slice(i * chunkSize, (i + 1) * chunkSize) + ); + } + + setFrequencyBands(chunks); + }; + + const interval = setInterval(updateVolume, 10); + + return () => { + source.disconnect(); + clearInterval(interval); + }; + }, [track, loPass, hiPass, bands]); + + return frequencyBands; +}; + +export const useAutoScroll = (ref: React.RefObject) => { + + const callback: MutationCallback = (mutationList, observer) => { + mutationList.forEach((mutation) => { + switch (mutation.type) { + case "childList": + if (!ref.current) { + return + } + ref.current.scrollTop = ref.current.scrollHeight; + break; + } + }) + } + + useEffect(() => { + if (!ref.current) { + return; + } + const observer = new MutationObserver(callback); + observer.observe(ref.current, { + childList: true, + subtree: true + }); + + return () => { + observer.disconnect(); + }; + }, [ref]); +} + +export const useSmallScreen = () => { + const screens = useBreakpoint(); + + const xs = useMemo(() => { + return !screens.sm && screens.xs + }, [screens]) + + const sm = useMemo(() => { + return !screens.md && screens.sm + }, [screens]) + + return { + xs, + sm, + isSmallScreen: xs || sm + } +} + +export const usePrevious = (value: any) => { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +}; + + diff --git a/demo/src/common/index.ts b/demo/src/common/index.ts new file mode 100644 index 00000000..3c2b0300 --- /dev/null +++ b/demo/src/common/index.ts @@ -0,0 +1,6 @@ +export * from "./hooks" +export * from "./constant" +export * from "./utils" +export * from "./storage" +export * from "./request" +export * from "./mock" diff --git a/demo/src/common/mock.ts b/demo/src/common/mock.ts new file mode 100644 index 00000000..db1e2ff8 --- /dev/null +++ b/demo/src/common/mock.ts @@ -0,0 +1,41 @@ +import { getRandomUserId } from "./utils" +import { IChatItem } 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.", +] + + +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 genRandomChatList = (num: number = 10): IChatItem[] => { + const arr: IChatItem[] = [] + for (let i = 0; i < num; i++) { + const type = Math.random() > 0.5 ? "agent" : "user" + arr.push({ + userId: getRandomUserId(), + userName: type == "agent" ? "Agent" : "You", + text: genRandomParagraph(3), + type, + time: Date.now(), + }) + } + + return arr +} diff --git a/demo/src/common/request.ts b/demo/src/common/request.ts new file mode 100644 index 00000000..0c65e352 --- /dev/null +++ b/demo/src/common/request.ts @@ -0,0 +1,99 @@ +import { genUUID } from "./utils" +import { Language } from "@/types" +import axios from "axios" + +interface StartRequestConfig { + channel: string + userId: number, + graphName: string, + language: Language, + voiceType: "male" | "female" +} + +interface GenAgoraDataConfig { + userId: string | number + channel: string +} + +export const apiGenAgoraData = async (config: GenAgoraDataConfig) => { + // the request will be rewrite at next.config.mjs to send to $AGENT_SERVER_URL + const url = `/api/token/generate` + const { userId, channel } = config + const data = { + request_id: genUUID(), + uid: userId, + channel_name: channel + } + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} + return resp +} + +export const apiStartService = async (config: StartRequestConfig): Promise => { + // look at app/api/agents/start/route.tsx for the server-side implementation + const url = `/api/agents/start` + const { channel, userId, graphName, language, voiceType } = config + const data = { + request_id: genUUID(), + channel_name: channel, + user_uid: userId, + graph_name: graphName, + language, + voice_type: voiceType + } + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} + return resp +} + +export const apiStopService = async (channel: string) => { + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL + const url = `/api/agents/stop` + const data = { + request_id: genUUID(), + channel_name: channel + } + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} + return resp +} + +export const apiGetDocumentList = async () => { + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL + const url = `/api/vector/document/preset/list` + let resp: any = await axios.get(url) + resp = (resp.data) || {} + if (resp.code !== "0") { + throw new Error(resp.msg) + } + return resp +} + +export const apiUpdateDocument = async (options: { channel: string, collection: string, fileName: string }) => { + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL + const url = `/api/vector/document/update` + const { channel, collection, fileName } = options + const data = { + request_id: genUUID(), + channel_name: channel, + collection: collection, + file_name: fileName + } + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} + return resp +} + + +// ping/pong +export const apiPing = async (channel: string) => { + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL + const url = `/api/agents/ping` + const data = { + request_id: genUUID(), + channel_name: channel + } + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} + return resp +} diff --git a/demo/src/common/storage.ts b/demo/src/common/storage.ts new file mode 100644 index 00000000..ed96083d --- /dev/null +++ b/demo/src/common/storage.ts @@ -0,0 +1,21 @@ +import { IOptions } from "@/types" +import { OPTIONS_KEY, DEFAULT_OPTIONS } from "./constant" + +export const getOptionsFromLocal = () => { + if (typeof window !== "undefined") { + const data = localStorage.getItem(OPTIONS_KEY) + if (data) { + return JSON.parse(data) + } + } + return DEFAULT_OPTIONS +} + + +export const setOptionsToLocal = (options: IOptions) => { + if (typeof window !== "undefined") { + localStorage.setItem(OPTIONS_KEY, JSON.stringify(options)) + } +} + + diff --git a/demo/src/common/utils.ts b/demo/src/common/utils.ts new file mode 100644 index 00000000..1d6f0d00 --- /dev/null +++ b/demo/src/common/utils.ts @@ -0,0 +1,59 @@ +export const genRandomString = (length: number = 10) => { + let result = ''; + const characters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + + return result; +} + + +export const getRandomUserId = (): number => { + return Math.floor(Math.random() * 99999) + 100000 +} + +export const getRandomChannel = (number = 6) => { + return "agora_" + genRandomString(number) +} + + +export const sleep = (ms: number) => { + return new Promise(resolve => setTimeout(resolve, ms)); +} + + +export const normalizeFrequencies = (frequencies: Float32Array) => { + const normalizeDb = (value: number) => { + const minDb = -100; + const maxDb = -10; + let db = 1 - (Math.max(minDb, Math.min(maxDb, value)) * -1) / 100; + db = Math.sqrt(db); + + return db; + }; + + // Normalize all frequency values + return frequencies.map((value) => { + if (value === -Infinity) { + return 0; + } + return normalizeDb(value); + }); +}; + + +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 isMobile = () => { + return /Mobile|iPhone|iPad|Android|Windows Phone/i.test(navigator.userAgent) +} \ No newline at end of file diff --git a/demo/src/components/authInitializer/index.tsx b/demo/src/components/authInitializer/index.tsx new file mode 100644 index 00000000..5ef763a1 --- /dev/null +++ b/demo/src/components/authInitializer/index.tsx @@ -0,0 +1,29 @@ +"use client" + +import { ReactNode, useEffect } from "react" +import { useAppDispatch, getOptionsFromLocal } from "@/common" +import { setOptions, reset } from "@/store/reducers/global" + +interface AuthInitializerProps { + children: ReactNode; +} + +const AuthInitializer = (props: AuthInitializerProps) => { + const { children } = props; + const dispatch = useAppDispatch() + + useEffect(() => { + if (typeof window !== "undefined") { + const options = getOptionsFromLocal() + if (options) { + dispatch(reset()) + dispatch(setOptions(options)) + } + } + }, [dispatch]) + + return children +} + + +export default AuthInitializer; diff --git a/demo/src/components/customSelect/index.module.scss b/demo/src/components/customSelect/index.module.scss new file mode 100644 index 00000000..0649e994 --- /dev/null +++ b/demo/src/components/customSelect/index.module.scss @@ -0,0 +1,22 @@ +.selectWrapper { + position: relative; + + .prefixIconWrapper { + position: absolute; + z-index: 1; + width: 3rem; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + :global(.customSelect) { + width: 100%; + + :global(.ant-select-selector) { + padding-left: calc(3rem - 8px) !important; + } + } + +} diff --git a/demo/src/components/customSelect/index.tsx b/demo/src/components/customSelect/index.tsx new file mode 100644 index 00000000..8dd1b188 --- /dev/null +++ b/demo/src/components/customSelect/index.tsx @@ -0,0 +1,19 @@ +import { Select, SelectProps } from "antd" +import styles from "./index.module.scss" + +type CustomSelectProps = SelectProps & { + prefixIcon?: React.ReactNode; +} + +const CustomSelect = (props: CustomSelectProps) => { + + const { prefixIcon, className, ...rest } = props; + + return
+ {prefixIcon &&
{prefixIcon}
} + +
+} + + +export default CustomSelect diff --git a/demo/src/components/icons/cam/index.tsx b/demo/src/components/icons/cam/index.tsx new file mode 100644 index 00000000..628e651c --- /dev/null +++ b/demo/src/components/icons/cam/index.tsx @@ -0,0 +1,17 @@ +import camMuteSvg from "@/assets/cam_mute.svg" +import camUnMuteSvg from "@/assets/cam_unmute.svg" +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) + } +} diff --git a/demo/src/components/icons/colorPicker/index.tsx b/demo/src/components/icons/colorPicker/index.tsx new file mode 100644 index 00000000..81efcb12 --- /dev/null +++ b/demo/src/components/icons/colorPicker/index.tsx @@ -0,0 +1,6 @@ +import { IconProps } from "../types" +import ColorPickerSvg from "@/assets/color_picker.svg" + +export const ColorPickerIcon = (props: IconProps) => { + return +} diff --git a/demo/src/components/icons/github/index.tsx b/demo/src/components/icons/github/index.tsx new file mode 100644 index 00000000..cee01b8b --- /dev/null +++ b/demo/src/components/icons/github/index.tsx @@ -0,0 +1,6 @@ +import { IconProps } from "../types" +import GithubSvg from "@/assets/github.svg" + +export const GithubIcon = (props: IconProps) => { + return +} diff --git a/demo/src/components/icons/index.tsx b/demo/src/components/icons/index.tsx new file mode 100644 index 00000000..e303674a --- /dev/null +++ b/demo/src/components/icons/index.tsx @@ -0,0 +1,10 @@ +export * from "./mic" +export * from "./cam" +export * from "./network" +export * from "./github" +export * from "./transcription" +export * from "./logo" +export * from "./info" +export * from "./colorPicker" +export * from "./voice" +export * from "./pdf" diff --git a/demo/src/components/icons/info/index.tsx b/demo/src/components/icons/info/index.tsx new file mode 100644 index 00000000..cf783be9 --- /dev/null +++ b/demo/src/components/icons/info/index.tsx @@ -0,0 +1,6 @@ +import { IconProps } from "../types" +import InfoSvg from "@/assets/info.svg" + +export const InfoIcon = (props: IconProps) => { + return +} diff --git a/demo/src/components/icons/logo/index.tsx b/demo/src/components/icons/logo/index.tsx new file mode 100644 index 00000000..f86d5246 --- /dev/null +++ b/demo/src/components/icons/logo/index.tsx @@ -0,0 +1,8 @@ +import { IconProps } from "../types" +import LogoSvg from "@/assets/logo.svg" +import SmallLogoSvg from "@/assets/logo_small.svg" + +export const LogoIcon = (props: IconProps) => { + const { size = "default" } = props + return size == "small" ? : +} diff --git a/demo/src/components/icons/mic/index.tsx b/demo/src/components/icons/mic/index.tsx new file mode 100644 index 00000000..0a693033 --- /dev/null +++ b/demo/src/components/icons/mic/index.tsx @@ -0,0 +1,23 @@ +import { IconProps } from "../types" +import micMuteSvg from "@/assets/mic_mute.svg" +import micUnMuteSvg from "@/assets/mic_unmute.svg" + +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, + }) + } +} diff --git a/demo/src/components/icons/network/index.tsx b/demo/src/components/icons/network/index.tsx new file mode 100644 index 00000000..1950cda7 --- /dev/null +++ b/demo/src/components/icons/network/index.tsx @@ -0,0 +1,33 @@ +import averageSvg from "@/assets/network/average.svg" +import goodSvg from "@/assets/network/good.svg" +import poorSvg from "@/assets/network/poor.svg" +import disconnectedSvg from "@/assets/network/disconnected.svg" +import excellentSvg from "@/assets/network/excellent.svg" + +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) + } +} diff --git a/demo/src/components/icons/pdf/index.tsx b/demo/src/components/icons/pdf/index.tsx new file mode 100644 index 00000000..83de8b2d --- /dev/null +++ b/demo/src/components/icons/pdf/index.tsx @@ -0,0 +1,6 @@ +import { IconProps } from "../types" +import PdfSvg from "@/assets/pdf.svg" + +export const PdfIcon = (props: IconProps) => { + return +} diff --git a/demo/src/components/icons/transcription/index.tsx b/demo/src/components/icons/transcription/index.tsx new file mode 100644 index 00000000..757adbce --- /dev/null +++ b/demo/src/components/icons/transcription/index.tsx @@ -0,0 +1,6 @@ +import { IconProps } from "../types" +import TranscriptionSvg from "@/assets/transcription.svg" + +export const TranscriptionIcon = (props: IconProps) => { + return +} diff --git a/demo/src/components/icons/types.ts b/demo/src/components/icons/types.ts new file mode 100644 index 00000000..c37e8133 --- /dev/null +++ b/demo/src/components/icons/types.ts @@ -0,0 +1,10 @@ +export interface IconProps { + width?: number + height?: number + color?: string + viewBox?: string + size?: "small" | "default" + // style?: React.CSSProperties + transform?: string + onClick?: () => void +} diff --git a/demo/src/components/icons/voice/index.tsx b/demo/src/components/icons/voice/index.tsx new file mode 100644 index 00000000..87164cea --- /dev/null +++ b/demo/src/components/icons/voice/index.tsx @@ -0,0 +1,6 @@ +import { IconProps } from "../types" +import VoiceSvg from "@/assets/voice.svg" + +export const VoiceIcon = (props: IconProps) => { + return +} diff --git a/demo/src/components/loginCard/index.module.scss b/demo/src/components/loginCard/index.module.scss new file mode 100644 index 00000000..966ebc20 --- /dev/null +++ b/demo/src/components/loginCard/index.module.scss @@ -0,0 +1,112 @@ +.card { + position: absolute; + top: 45%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + width: 368px; + padding: 100px 24px 40px 24px; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 20px; + border: 1px solid #20272D; + background: linear-gradient(154deg, rgba(31, 69, 141, 0.16) 0%, rgba(31, 69, 141, 0.00) 30%), linear-gradient(153deg, rgba(31, 54, 97, 0.00) 53.75%, #1F458D 100%), rgba(15, 15, 17, 0.10); + box-shadow: 0px 3.999px 48.988px 0px rgba(0, 7, 72, 0.12); + backdrop-filter: blur(8.8px); + + .top { + .github { + position: absolute; + right: 24px; + top: 24px; + display: flex; + padding: 4px 8px 4px 4px; + align-items: center; + gap: 4px; + border-radius: 100px; + border: 1px solid #2B2F36; + cursor: pointer; + + .text { + color: var(--Grey-300, #EAECF0); + font-size: 12px; + line-height: 150%; + } + } + } + + .content { + + .title { + margin-bottom: 32px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + + .text { + margin-top: 8px; + color: var(--Grey-300, #EAECF0); + text-align: center; + font-size: 18px; + font-weight: 500; + } + } + + .section { + + input { + display: flex; + width: 320px; + flex-direction: column; + align-items: flex-start; + gap: 6px; + display: flex; + height: 38px; + padding: 12px 8px; + align-items: center; + gap: 8px; + align-self: stretch; + border-radius: 6px; + border: 1px solid #2B2F36; + box-shadow: 0px 4.282px 52.456px 0px rgba(0, 7, 72, 0.12); + backdrop-filter: blur(13px); + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.20); + } + + .btn { + display: flex; + padding: 10px 18px; + justify-content: center; + align-items: center; + gap: 8px; + align-self: stretch; + border-radius: 8px; + background: var(--primary-500-base, #0888FF); + box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); + cursor: pointer; + + .btnText { + color: var(---white, #FFF); + font-size: 16px; + font-weight: 500; + line-height: 24px; + } + } + } + + .section+.section { + margin-top: 24px; + } + + .version { + text-align: center; + margin-top: 32px; + color: var(--Grey-600, #667085); + line-height: 22px; + } + + } +} diff --git a/demo/src/components/loginCard/index.tsx b/demo/src/components/loginCard/index.tsx new file mode 100644 index 00000000..56a78986 --- /dev/null +++ b/demo/src/components/loginCard/index.tsx @@ -0,0 +1,77 @@ +"use client" + +import packageData from "../../../package.json" +import { useRouter } from 'next/navigation' +import { message } from "antd" +import { useState } from "react" +import { GithubIcon, LogoIcon } from "../icons" +import { GITHUB_URL, getRandomUserId, useAppDispatch, getRandomChannel } from "@/common" +import { setOptions } from "@/store/reducers/global" +import styles from "./index.module.scss" + + +const { version } = packageData + +const LoginCard = () => { + const dispatch = useAppDispatch() + const router = useRouter() + const [userName, setUserName] = useState("") + + const onClickGithub = () => { + if (typeof window !== "undefined") { + window.open(GITHUB_URL, "_blank") + } + } + + const onUserNameChange = (e: any) => { + let value = e.target.value + value = value.replace(/\s/g, ""); + setUserName(value) + } + + + + const onClickJoin = () => { + if (!userName) { + message.error("please input user name") + return + } + const userId = getRandomUserId() + dispatch(setOptions({ + userName, + channel: getRandomChannel(), + userId + })) + router.push("/home") + } + + + return
+
+ + + GitHub + +
+
+
+ + Astra - a multimodal interactive agent +
+
+ +
+
+
+ Join +
+
+
Version {version}
+
+
+ + + return +} + +export default LoginCard diff --git a/demo/src/components/pdfSelect/index.module.scss b/demo/src/components/pdfSelect/index.module.scss new file mode 100644 index 00000000..adb93280 --- /dev/null +++ b/demo/src/components/pdfSelect/index.module.scss @@ -0,0 +1,8 @@ +// .pdfSelect { + // min-width: 200px; + // max-width: 300px; + // } +.dropdownRender { + display: flex; + justify-content: flex-end; +} diff --git a/demo/src/components/pdfSelect/index.tsx b/demo/src/components/pdfSelect/index.tsx new file mode 100644 index 00000000..f593bf1d --- /dev/null +++ b/demo/src/components/pdfSelect/index.tsx @@ -0,0 +1,88 @@ +import { ReactElement, useState } from "react" +import { PdfIcon } from "@/components/icons" +import CustomSelect from "@/components/customSelect" +import { Divider, message } from 'antd'; +import { useEffect } from 'react'; +import { apiGetDocumentList, apiUpdateDocument, useAppSelector } from "@/common" +import PdfUpload from "./upload" +import { OptionType, IPdfData } from "@/types" + +import styles from "./index.module.scss" + +const PdfSelect = () => { + const options = useAppSelector(state => state.global.options) + const { channel } = options + const [pdfOptions, setPdfOptions] = useState([]) + const [selectedPdf, setSelectedPdf] = useState('') + const agentConnected = useAppSelector(state => state.global.agentConnected) + + + useEffect(() => { + if(agentConnected) { + getPDFOptions() + } else { + setPdfOptions([{ + value: '', + label: 'Please select a PDF file' + }]) + } + }, [agentConnected]) + + + const getPDFOptions = async () => { + const res = await apiGetDocumentList() + setPdfOptions([{ + value: '', + label: 'Please select a PDF file' + }].concat(res.data.map((item: any) => { + return { + value: item.collection, + label: item.file_name + } + }))) + setSelectedPdf('') + } + + const onUploadSuccess = (data: IPdfData) => { + setPdfOptions([...pdfOptions, { + value: data.collection, + label: data.fileName + }]) + setSelectedPdf(data.collection) + } + + const pdfDropdownRender = (menu: ReactElement) => { + return <> + {menu} + +
+ +
+ + } + + + const onSelectPdf = async (val: string) => { + const item = pdfOptions.find(item => item.value === val) + if (!item) { + return message.error("Please select a PDF file") + } + setSelectedPdf(val) + await apiUpdateDocument({ + collection: val, + fileName: item.label, + channel + }) + } + + + return } + onChange={onSelectPdf} + value={selectedPdf} + options={pdfOptions} + dropdownRender={pdfDropdownRender} + className={styles.pdfSelect} placeholder="Select a PDF file"> +} + +export default PdfSelect diff --git a/demo/src/components/pdfSelect/upload/index.module.scss b/demo/src/components/pdfSelect/upload/index.module.scss new file mode 100644 index 00000000..fd559b5e --- /dev/null +++ b/demo/src/components/pdfSelect/upload/index.module.scss @@ -0,0 +1,7 @@ +.btn { + color: var(--theme-color, #EAECF0); + + &:hover { + color: var(--theme-color, #EAECF0) !important; + } +} diff --git a/demo/src/components/pdfSelect/upload/index.tsx b/demo/src/components/pdfSelect/upload/index.tsx new file mode 100644 index 00000000..4eab9684 --- /dev/null +++ b/demo/src/components/pdfSelect/upload/index.tsx @@ -0,0 +1,75 @@ +import { Select, Button, message, Upload, UploadProps } from "antd" +import { useState } from "react" +import { PlusOutlined, LoadingOutlined } from '@ant-design/icons'; +import { useAppSelector, genUUID } from "@/common" +import { IPdfData } from "@/types" + +import styles from "./index.module.scss" + +interface PdfSelectProps { + onSuccess?: (data: IPdfData) => void +} + +const PdfUpload = (props: PdfSelectProps) => { + const { onSuccess } = props + const agentConnected = useAppSelector(state => state.global.agentConnected) + const options = useAppSelector(state => state.global.options) + const { channel, userId } = options + + const [uploading, setUploading] = useState(false) + + const uploadProps: UploadProps = { + accept: "application/pdf", + maxCount: 1, + showUploadList: false, + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL + action: `/api/vector/document/upload`, + data: { + channel_name: channel, + uid: String(userId), + request_id: genUUID() + }, + onChange: (info) => { + const { file } = info + const { status, name } = file + if (status == "uploading") { + setUploading(true) + } else if (status == 'done') { + setUploading(false) + const { response } = file + if (response.code == "0") { + message.success(`Upload ${name} success`) + const { collection, file_name } = response.data + onSuccess && onSuccess({ + fileName: file_name, + collection + }) + } else { + message.error(response.msg) + } + } else if (status == 'error') { + setUploading(false) + message.error(`Upload ${name} failed`) + } + } + } + + const onClickUploadPDF = (e: any) => { + if (!agentConnected) { + message.error("Please connect to agent first") + e.stopPropagation() + } + } + + + return + + +} + + +export default PdfUpload diff --git a/demo/src/manager/events.ts b/demo/src/manager/events.ts new file mode 100644 index 00000000..055c6d87 --- /dev/null +++ b/demo/src/manager/events.ts @@ -0,0 +1,51 @@ +import { EventHandler } from "./types" + +export class AGEventEmitter { + private readonly _eventMap: Map[]> = new Map() + + once(evt: Key, cb: T[Key]) { + const wrapper = (...args: any[]) => { + this.off(evt, wrapper as any) + ;(cb as any)(...args) + } + this.on(evt, wrapper as any) + return this + } + + on(evt: Key, cb: T[Key]) { + const cbs = this._eventMap.get(evt) ?? [] + cbs.push(cb as any) + this._eventMap.set(evt, cbs) + return this + } + + off(evt: Key, cb: T[Key]) { + const cbs = this._eventMap.get(evt) + if (cbs) { + this._eventMap.set( + evt, + cbs.filter((it) => it !== cb), + ) + } + return this + } + + removeAllEventListeners(): void { + this._eventMap.clear() + } + + emit(evt: Key, ...args: any[]) { + const cbs = this._eventMap.get(evt) ?? [] + for (const cb of cbs) { + try { + cb && cb(...args) + } catch (e) { + // cb exception should not affect other callbacks + const error = e as Error + const details = error.stack || error.message + console.error(`[event] handling event ${evt.toString()} fail: ${details}`) + } + } + return this + } +} diff --git a/demo/src/manager/index.ts b/demo/src/manager/index.ts new file mode 100644 index 00000000..fa9dfbf2 --- /dev/null +++ b/demo/src/manager/index.ts @@ -0,0 +1 @@ +export * from "./rtc" diff --git a/demo/src/manager/rtc/index.ts b/demo/src/manager/rtc/index.ts new file mode 100644 index 00000000..e9fd6272 --- /dev/null +++ b/demo/src/manager/rtc/index.ts @@ -0,0 +1,2 @@ +export * from "./rtc" +export * from "./types" diff --git a/demo/src/manager/rtc/rtc.ts b/demo/src/manager/rtc/rtc.ts new file mode 100644 index 00000000..4139d89e --- /dev/null +++ b/demo/src/manager/rtc/rtc.ts @@ -0,0 +1,199 @@ +"use client" + +import protoRoot from "@/protobuf/SttMessage_es6.js" +import AgoraRTC, { + IAgoraRTCClient, + IMicrophoneAudioTrack, + IRemoteAudioTrack, + UID, +} from "agora-rtc-sdk-ng" +import { ITextItem } from "@/types" +import { AGEventEmitter } from "../events" +import { RtcEvents, IUserTracks } from "./types" +import { apiGenAgoraData } from "@/common" + +export class RtcManager extends AGEventEmitter { + private _joined + client: IAgoraRTCClient + localTracks: IUserTracks + + constructor() { + super() + this._joined = false + this.localTracks = {} + this.client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }) + this._listenRtcEvents() + } + + async join({ channel, userId }: { channel: string; userId: number }) { + if (!this._joined) { + const res = await apiGenAgoraData({ channel, userId }) + const { code, data } = res + if (code != 0) { + throw new Error("Failed to get Agora token") + } + const { appId, token } = data + await this.client?.join(appId, channel, token, userId) + this._joined = true + } + } + + async createTracks() { + try { + const videoTrack = await AgoraRTC.createCameraVideoTrack() + this.localTracks.videoTrack = videoTrack + } catch (err) { + console.error("Failed to create video track", err) + } + try { + const audioTrack = await AgoraRTC.createMicrophoneAudioTrack() + this.localTracks.audioTrack = audioTrack + } catch (err) { + console.error("Failed to create audio track", err) + } + this.emit("localTracksChanged", this.localTracks) + } + + async publish() { + const tracks = [] + if (this.localTracks.videoTrack) { + tracks.push(this.localTracks.videoTrack) + } + if (this.localTracks.audioTrack) { + tracks.push(this.localTracks.audioTrack) + } + if (tracks.length) { + await this.client.publish(tracks) + } + } + + async destroy() { + this.localTracks?.audioTrack?.close() + this.localTracks?.videoTrack?.close() + if (this._joined) { + await this.client?.leave() + } + this._resetData() + } + + // ----------- public methods ------------ + + // -------------- private methods -------------- + private _listenRtcEvents() { + this.client.on("network-quality", (quality) => { + this.emit("networkQuality", quality) + }) + this.client.on("user-published", async (user, mediaType) => { + await this.client.subscribe(user, mediaType) + if (mediaType === "audio") { + this._playAudio(user.audioTrack) + } + this.emit("remoteUserChanged", { + userId: user.uid, + audioTrack: user.audioTrack, + videoTrack: user.videoTrack, + }) + }) + this.client.on("user-unpublished", async (user, mediaType) => { + await this.client.unsubscribe(user, mediaType) + this.emit("remoteUserChanged", { + userId: user.uid, + audioTrack: user.audioTrack, + videoTrack: user.videoTrack, + }) + }) + this.client.on("stream-message", (uid: UID, stream: any) => { + this._parseData(stream) + }) + } + + private _parseData(data: any): ITextItem | void { + let decoder = new TextDecoder('utf-8'); + let decodedMessage = decoder.decode(data); + const textstream = JSON.parse(decodedMessage); + + console.log("[test] textstream raw data", JSON.stringify(textstream)); + + const { stream_id, is_final, text, text_ts, data_type, message_id, part_number, total_parts } = textstream; + + if (total_parts > 0) { + // If message is split, handle it accordingly + this._handleSplitMessage(message_id, part_number, total_parts, stream_id, is_final, text, text_ts); + } else { + // If there is no message_id, treat it as a complete message + this._handleCompleteMessage(stream_id, is_final, text, text_ts); + } + } + + private messageCache: { [key: string]: { parts: string[], totalParts: number } } = {}; + + /** + * Handle complete messages (not split). + */ + private _handleCompleteMessage(stream_id: number, is_final: boolean, text: string, text_ts: number): void { + const textItem: ITextItem = { + uid: `${stream_id}`, + time: text_ts, + dataType: "transcribe", + text: text, + isFinal: is_final + }; + + if (text.trim().length > 0) { + this.emit("textChanged", textItem); + } + } + + /** + * Handle split messages, track parts, and reassemble once all parts are received. + */ + private _handleSplitMessage( + message_id: string, + part_number: number, + total_parts: number, + stream_id: number, + is_final: boolean, + text: string, + text_ts: number + ): void { + // Ensure the messageCache entry exists for this message_id + if (!this.messageCache[message_id]) { + this.messageCache[message_id] = { parts: [], totalParts: total_parts }; + } + + const cache = this.messageCache[message_id]; + + // Store the received part at the correct index (part_number starts from 1, so we use part_number - 1) + cache.parts[part_number - 1] = text; + + // Check if all parts have been received + const receivedPartsCount = cache.parts.filter(part => part !== undefined).length; + + if (receivedPartsCount === total_parts) { + // All parts have been received, reassemble the message + const fullText = cache.parts.join(''); + + // Now that the message is reassembled, handle it like a complete message + this._handleCompleteMessage(stream_id, is_final, fullText, text_ts); + + // Remove the cached message since it is now fully processed + delete this.messageCache[message_id]; + } + } + + + _playAudio(audioTrack: IMicrophoneAudioTrack | IRemoteAudioTrack | undefined) { + if (audioTrack && !audioTrack.isPlaying) { + audioTrack.play() + } + } + + + private _resetData() { + this.localTracks = {} + this._joined = false + } +} + + +export const rtcManager = new RtcManager() diff --git a/demo/src/manager/rtc/types.ts b/demo/src/manager/rtc/types.ts new file mode 100644 index 00000000..15e3f515 --- /dev/null +++ b/demo/src/manager/rtc/types.ts @@ -0,0 +1,25 @@ +import { + UID, + IAgoraRTCRemoteUser, + IAgoraRTCClient, + ICameraVideoTrack, + IMicrophoneAudioTrack, + NetworkQuality, +} from "agora-rtc-sdk-ng" +import { ITextItem } from "@/types" + +export interface IRtcUser extends IUserTracks { + userId: UID +} + +export interface RtcEvents { + remoteUserChanged: (user: IRtcUser) => void + localTracksChanged: (tracks: IUserTracks) => void + networkQuality: (quality: NetworkQuality) => void + textChanged: (text: ITextItem) => void +} + +export interface IUserTracks { + videoTrack?: ICameraVideoTrack + audioTrack?: IMicrophoneAudioTrack +} diff --git a/demo/src/manager/types.ts b/demo/src/manager/types.ts new file mode 100644 index 00000000..50e5b1c0 --- /dev/null +++ b/demo/src/manager/types.ts @@ -0,0 +1 @@ +export type EventHandler = (...data: T) => void diff --git a/demo/src/middleware.tsx b/demo/src/middleware.tsx new file mode 100644 index 00000000..724e0b4d --- /dev/null +++ b/demo/src/middleware.tsx @@ -0,0 +1,44 @@ +// middleware.js +import { NextRequest, NextResponse } from 'next/server'; + + +const { AGENT_SERVER_URL } = process.env; + +// Check if environment variables are available +if (!AGENT_SERVER_URL) { + throw "Environment variables AGENT_SERVER_URL are not available"; +} + +export function middleware(req: NextRequest) { + const { pathname } = req.nextUrl; + + if (pathname.startsWith('/api/agents/')) { + if (!pathname.startsWith('/api/agents/start')) { + + // Proxy all other agents API requests + const url = req.nextUrl.clone(); + url.href = `${AGENT_SERVER_URL}${pathname.replace('/api/agents/', '/')}`; + + // console.log(`Rewriting request to ${url.href}`); + return NextResponse.rewrite(url); + } + } else if (pathname.startsWith('/api/vector/')) { + + // Proxy all other documents requests + const url = req.nextUrl.clone(); + url.href = `${AGENT_SERVER_URL}${pathname.replace('/api/vector/', '/vector/')}`; + + // console.log(`Rewriting request to ${url.href}`); + return NextResponse.rewrite(url); + } else if (pathname.startsWith('/api/token/')) { + // Proxy all other documents requests + const url = req.nextUrl.clone(); + url.href = `${AGENT_SERVER_URL}${pathname.replace('/api/token/', '/token/')}`; + + // console.log(`Rewriting request to ${url.href}`); + return NextResponse.rewrite(url); + } else { + return NextResponse.next(); + } + +} \ No newline at end of file diff --git a/demo/src/platform/mobile/chat/chatItem/index.module.scss b/demo/src/platform/mobile/chat/chatItem/index.module.scss new file mode 100644 index 00000000..27057120 --- /dev/null +++ b/demo/src/platform/mobile/chat/chatItem/index.module.scss @@ -0,0 +1,86 @@ +.agentChatItem { + width: 100%; + display: flex; + justify-content: flex-start; + + .left { + flex: 0 0 auto; + display: flex; + width: 32px; + height: 32px; + padding: 10px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 200px; + background: var(--Grey-700, #475467); + + .userName { + color: var(---white, #FFF); + text-align: center; + font-size: 14px; + font-weight: 500; + line-height: 150%; + } + } + + .right { + margin-left: 12px; + + .userName { + font-size: 14px; + font-weight: 500; + line-height: 20px; + color: var(--theme-color, #667085) !important; + } + + + .agent { + color: var(--theme-color, #EAECF0) !important; + } + + } +} + +.userChatItem { + width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-end; + + .userName { + text-align: right; + color: var(--Grey-600, #667085); + font-weight: 500; + line-height: 20px; + } + + + +} + + +.chatItem { + .text { + margin-top: 6px; + color: #FFF; + display: flex; + padding: 8px 14px; + flex-direction: column; + justify-content: left; + font-size: 14px; + font-weight: 400; + line-height: 21px; + white-space: pre-wrap; + border-radius: 0px 8px 8px 8px; + border: 1px solid #272A2F; + background: #1E2024; + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25); + } +} + +.chatItem+.chatItem { + margin-top: 14px; +} diff --git a/demo/src/platform/mobile/chat/chatItem/index.tsx b/demo/src/platform/mobile/chat/chatItem/index.tsx new file mode 100644 index 00000000..bde3350c --- /dev/null +++ b/demo/src/platform/mobile/chat/chatItem/index.tsx @@ -0,0 +1,50 @@ +import { IChatItem } from "@/types" +import styles from "./index.module.scss" + +interface ChatItemProps { + data: IChatItem +} + + +const AgentChatItem = (props: ChatItemProps) => { + const { data } = props + const { text } = data + + + return
+ + Ag + + +
Agent
+
+ {text} +
+
+
+} + +const UserChatItem = (props: ChatItemProps) => { + const { data } = props + const { text } = data + + return
+
You
+
{text}
+
+} + + +const ChatItem = (props: ChatItemProps) => { + const { data } = props + + + return ( + data.type === "agent" ? : + ); + + +} + + +export default ChatItem diff --git a/demo/src/platform/mobile/chat/index.module.scss b/demo/src/platform/mobile/chat/index.module.scss new file mode 100644 index 00000000..8ded1f2c --- /dev/null +++ b/demo/src/platform/mobile/chat/index.module.scss @@ -0,0 +1,78 @@ +.chat { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + background: #181A1D; + overflow: hidden; + + .header { + display: flex; + flex-direction: column; + align-items: stretch; + row-gap: 10px; + border-bottom: 1px solid #272A2F; + width: 100%; + + + .text { + margin-left: 4px; + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-weight: 600; + height: 40px; + line-height: 40px; + letter-spacing: 0.449px; + } + + .languageSelect { + width: 100%; + } + + + + + } + + .content { + margin-top: 16px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + align-self: stretch; + overflow-y: auto; + + + &::-webkit-scrollbar { + width: 6px + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: #6B6B6B; + border-radius: 4px; + } + } + + +} + + +.dropdownRender { + display: flex; + justify-content: flex-end; + + + .btn { + color: var(--theme-color, #EAECF0); + + &:hover { + color: var(--theme-color, #EAECF0) !important; + } + } +} \ No newline at end of file diff --git a/demo/src/platform/mobile/chat/index.tsx b/demo/src/platform/mobile/chat/index.tsx new file mode 100644 index 00000000..bb071d4e --- /dev/null +++ b/demo/src/platform/mobile/chat/index.tsx @@ -0,0 +1,65 @@ +import { ReactElement, useEffect, useContext, useState } from "react" +import ChatItem from "./chatItem" +import { IChatItem } from "@/types" +import { useAppDispatch, useAutoScroll, LANGUAGE_OPTIONS, useAppSelector, GRAPH_OPTIONS, isRagGraph } from "@/common" +import { setGraphName, setLanguage } from "@/store/reducers/global" +import { Select, } from 'antd'; +import { MenuContext } from "../menu/context" +import PdfSelect from "@/components/pdfSelect" + +import styles from "./index.module.scss" + + +const Chat = () => { + const chatItems = useAppSelector(state => state.global.chatItems) + const language = useAppSelector(state => state.global.language) + const agentConnected = useAppSelector(state => state.global.agentConnected) + const graphName = useAppSelector(state => state.global.graphName) + const dispatch = useAppDispatch() + // genRandomChatList + // const [chatItems, setChatItems] = useState([]) + const context = useContext(MenuContext); + + if (!context) { + throw new Error("MenuContext is not found") + } + + const { scrollToBottom } = context; + + + useEffect(() => { + scrollToBottom() + }, [chatItems, scrollToBottom]) + + + + const onLanguageChange = (val: any) => { + dispatch(setLanguage(val)) + } + + const onGraphNameChange = (val: any) => { + dispatch(setGraphName(val)) + } + + + return
+
+ + + {isRagGraph(graphName) ? : null} +
+
+ {chatItems.map((item, index) => { + return + })} +
+
+} + + +export default Chat diff --git a/demo/src/platform/mobile/description/index.module.scss b/demo/src/platform/mobile/description/index.module.scss new file mode 100644 index 00000000..7305f5a7 --- /dev/null +++ b/demo/src/platform/mobile/description/index.module.scss @@ -0,0 +1,71 @@ +.description { + position: relative; + display: flex; + padding: 12px 16px; + height: 60px; + align-items: center; + gap: 12px; + align-self: stretch; + border-bottom: 1px solid #272A2F; + background: #181A1D; + box-sizing: border-box; + + .title { + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-style: normal; + font-weight: 600; + flex: 1 1 auto; + /* 21px */ + letter-spacing: 0.449px; + } + + .text { + margin-left: 12px; + flex: 1 1 auto; + color: var(--Grey-600, #667085); + font-size: 14px; + font-style: normal; + font-weight: 400; + } + + + .btnConnect { + width: 150px; + display: flex; + padding: 8px 14px; + justify-content: center; + align-items: center; + gap: 8px; + align-self: stretch; + border-radius: 6px; + background: var(--theme-color, #0888FF); + box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); + cursor: pointer; + user-select: none; + caret-color: transparent; + box-sizing: border-box; + + .btnText { + color: var(---White, #FFF); + font-size: 14px; + font-weight: 500; + line-height: 20px; + } + + .btnText.disconnect { + color: var(--Error-400-T, #E95C7B); + } + + .loading { + margin-left: 4px; + } + } + + + .btnConnect.disconnect { + background: #181A1D; + border: 1px solid var(--Error-400-T, #E95C7B); + } + +} diff --git a/demo/src/platform/mobile/description/index.tsx b/demo/src/platform/mobile/description/index.tsx new file mode 100644 index 00000000..7473d550 --- /dev/null +++ b/demo/src/platform/mobile/description/index.tsx @@ -0,0 +1,100 @@ +import { setAgentConnected } from "@/store/reducers/global" +import { + DESCRIPTION, useAppDispatch, useAppSelector, apiPing, genUUID, + apiStartService, apiStopService +} from "@/common" +import { message } from "antd" +import { useEffect, useState } from "react" +import { LoadingOutlined, } from "@ant-design/icons" +import styles from "./index.module.scss" + +let intervalId: any + +const Description = () => { + const dispatch = useAppDispatch() + const agentConnected = useAppSelector(state => state.global.agentConnected) + const channel = useAppSelector(state => state.global.options.channel) + const userId = useAppSelector(state => state.global.options.userId) + const language = useAppSelector(state => state.global.language) + const voiceType = useAppSelector(state => state.global.voiceType) + const graphName = useAppSelector(state => state.global.graphName) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (channel) { + checkAgentConnected() + } + }, [channel]) + + + const checkAgentConnected = async () => { + const res: any = await apiPing(channel) + if (res?.code == 0) { + dispatch(setAgentConnected(true)) + } + } + + const onClickConnect = async () => { + if (loading) { + return + } + setLoading(true) + if (agentConnected) { + await apiStopService(channel) + dispatch(setAgentConnected(false)) + message.success("Agent disconnected") + stopPing() + } else { + const res = await apiStartService({ + channel, + userId, + graphName, + language, + voiceType + }) + const { code, msg } = res || {} + if (code != 0) { + if (code == "10001") { + message.error("The number of users experiencing the program simultaneously has exceeded the limit. Please try again later.") + } else { + message.error(`code:${code},msg:${msg}`) + } + setLoading(false) + throw new Error(msg) + } + dispatch(setAgentConnected(true)) + message.success("Agent connected") + startPing() + } + setLoading(false) + } + + const startPing = () => { + if (intervalId) { + stopPing() + } + intervalId = setInterval(() => { + apiPing(channel) + }, 3000) + } + + const stopPing = () => { + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + + return
+ Description + + + {!agentConnected ? "Connect" : "Disconnect"} + {loading ? : null} + + +
+} + + +export default Description diff --git a/demo/src/platform/mobile/entry/index.module.scss b/demo/src/platform/mobile/entry/index.module.scss new file mode 100644 index 00000000..41322c12 --- /dev/null +++ b/demo/src/platform/mobile/entry/index.module.scss @@ -0,0 +1,18 @@ +.entry { + position: relative; + height: 100%; + box-sizing: border-box; + + .content { + position: relative; + padding: 16px; + box-sizing: border-box; + + + .body { + margin-top: 16px; + display: flex; + gap: 24px; + } + } +} diff --git a/demo/src/platform/mobile/entry/index.tsx b/demo/src/platform/mobile/entry/index.tsx new file mode 100644 index 00000000..c5f51d5c --- /dev/null +++ b/demo/src/platform/mobile/entry/index.tsx @@ -0,0 +1,30 @@ +import Chat from "../chat" +import Description from "../description" +import Rtc from "../rtc" +import Header from "../header" +import Menu, { IMenuData } from "../menu" +import styles from "./index.module.scss" + + +const MenuData: IMenuData[] = [{ + name: "Agent", + component: , +}, { + name: "Chat", + component: , +}] + + +const MobileEntry = () => { + + return
+
+ +
+ +
+
+} + + +export default MobileEntry diff --git a/demo/src/platform/mobile/header/index.module.scss b/demo/src/platform/mobile/header/index.module.scss new file mode 100644 index 00000000..707e4215 --- /dev/null +++ b/demo/src/platform/mobile/header/index.module.scss @@ -0,0 +1,57 @@ +.header { + display: flex; + width: 100%; + height: 48px; + padding: 16px; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #24262A; + background: #1E2024; + box-shadow: 0px 12px 16px -4px rgba(8, 15, 52, 0.06), 0px 4px 6px -2px rgba(8, 15, 52, 0.03); + box-sizing: border-box; + z-index: 999; + + .logoWrapper { + display: flex; + align-items: center; + + .text { + margin-left: 8px; + color: var(---white, #FFF); + text-align: right; + font-family: Inter; + font-size: 16px; + font-weight: 500; + } + } + + .content { + padding-left: 12px; + display: flex; + align-items: center; + justify-content: flex-start; + height: 48px; + flex: 1 1 auto; + color: var(--Grey-300, #EAECF0); + font-size: 16px; + font-weight: 500; + line-height: 48px; + letter-spacing: 0.449px; + text-align: center; + + .text { + margin-left: 4px; + font-size: 12px; + } + } + + .links { + display: flex; + align-items: center; + gap: 8px; + + span { + display: flex; + } + } +} diff --git a/demo/src/platform/mobile/header/index.tsx b/demo/src/platform/mobile/header/index.tsx new file mode 100644 index 00000000..6e56a017 --- /dev/null +++ b/demo/src/platform/mobile/header/index.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useAppSelector, GITHUB_URL, useSmallScreen } from "@/common" +import Network from "./network" +import InfoPopover from "./infoPopover" +import StylePopover from "./stylePopover" +import { GithubIcon, LogoIcon, InfoIcon, ColorPickerIcon } from "@/components/icons" + +import styles from "./index.module.scss" + +const Header = () => { + const themeColor = useAppSelector(state => state.global.themeColor) + const options = useAppSelector(state => state.global.options) + const { channel } = options + + + const onClickGithub = () => { + if (typeof window !== "undefined") { + window.open(GITHUB_URL, "_blank") + } + } + + + + return
+ + + + + + + {channel} + + +
+ + + + + + + +
+
+} + + +export default Header diff --git a/demo/src/platform/mobile/header/infoPopover/index.module.scss b/demo/src/platform/mobile/header/infoPopover/index.module.scss new file mode 100644 index 00000000..cd3f72f8 --- /dev/null +++ b/demo/src/platform/mobile/header/infoPopover/index.module.scss @@ -0,0 +1,43 @@ +.info { + display: flex; + padding: 12px 16px; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + + .title { + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + } + + .item { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + + .title { + color: var(--Grey-600, #667085); + font-size: 14px; + font-weight: 400; + line-height: 150%; + } + + .content { + color: var(--theme-color, #FFF); + font-size: 14px; + font-weight: 400; + line-height: 150%; + } + } + + .slider { + height: 1px; + width: 100%; + background-color: #0D0F12; + } +} diff --git a/demo/src/platform/mobile/header/infoPopover/index.tsx b/demo/src/platform/mobile/header/infoPopover/index.tsx new file mode 100644 index 00000000..cd451418 --- /dev/null +++ b/demo/src/platform/mobile/header/infoPopover/index.tsx @@ -0,0 +1,57 @@ +import { useMemo } from "react" +import { useAppSelector } from "@/common" +import { Popover } from 'antd'; + + +import styles from "./index.module.scss" + +interface InfoPopoverProps { + children?: React.ReactNode +} + +const InfoPopover = (props: InfoPopoverProps) => { + const { children } = props + const options = useAppSelector(state => state.global.options) + const { channel, userId } = options + + const roomConnected = useAppSelector(state => state.global.roomConnected) + const agentConnected = useAppSelector(state => state.global.agentConnected) + + const roomConnectedText = useMemo(() => { + return roomConnected ? "TRUE" : "FALSE" + }, [roomConnected]) + + const agentConnectedText = useMemo(() => { + return agentConnected ? "TRUE" : "FALSE" + }, [agentConnected]) + + + + const content =
+
INFO
+
+ Room + {channel} +
+
+ Participant + {userId} +
+
+
STATUS
+
+
Room connected
+
{roomConnectedText}
+
+
+
Agent connected
+
{agentConnectedText}
+
+
+ + + return {children} + +} + +export default InfoPopover diff --git a/demo/src/platform/mobile/header/network/index.module.scss b/demo/src/platform/mobile/header/network/index.module.scss new file mode 100644 index 00000000..e69de29b diff --git a/demo/src/platform/mobile/header/network/index.tsx b/demo/src/platform/mobile/header/network/index.tsx new file mode 100644 index 00000000..92b4e33b --- /dev/null +++ b/demo/src/platform/mobile/header/network/index.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +import { rtcManager } from "@/manager" +import { NetworkQuality } from "agora-rtc-sdk-ng" +import { useEffect, useState } from "react" +import { NetworkIcon } from "@/components/icons" + +interface NetworkProps { + style?: React.CSSProperties +} + +const NetWork = (props: NetworkProps) => { + const { style } = props + + const [networkQuality, setNetworkQuality] = useState() + + useEffect(() => { + rtcManager.on("networkQuality", onNetworkQuality) + + return () => { + rtcManager.off("networkQuality", onNetworkQuality) + } + }, []) + + const onNetworkQuality = (quality: NetworkQuality) => { + setNetworkQuality(quality) + } + + return ( + + + + ) +} + +export default NetWork diff --git a/demo/src/platform/mobile/header/stylePopover/colorPicker/index.module.scss b/demo/src/platform/mobile/header/stylePopover/colorPicker/index.module.scss new file mode 100644 index 00000000..405e7781 --- /dev/null +++ b/demo/src/platform/mobile/header/stylePopover/colorPicker/index.module.scss @@ -0,0 +1,24 @@ +.colorPicker { + height: 24px; + display: flex; + align-items: center; + + :global(.react-colorful) { + width: 220px; + height: 8px; + } + + :global(.react-colorful__saturation) { + display: none; + } + + :global(.react-colorful__hue) { + border-radius: 8px !important; + height: 8px; + } + + :global(.react-colorful__pointer) { + width: 24px; + height: 24px; + } +} diff --git a/demo/src/platform/mobile/header/stylePopover/colorPicker/index.tsx b/demo/src/platform/mobile/header/stylePopover/colorPicker/index.tsx new file mode 100644 index 00000000..28163d77 --- /dev/null +++ b/demo/src/platform/mobile/header/stylePopover/colorPicker/index.tsx @@ -0,0 +1,22 @@ +"use client" + +import { HexColorPicker } from "react-colorful"; +import { useAppSelector, useAppDispatch } from "@/common" +import { setThemeColor } from "@/store/reducers/global" +import styles from "./index.module.scss"; + +const ColorPicker = () => { + const dispatch = useAppDispatch() + const themeColor = useAppSelector(state => state.global.themeColor) + + const onColorChange = (color: string) => { + console.log(color); + dispatch(setThemeColor(color)) + }; + + return
+ +
+}; + +export default ColorPicker; diff --git a/demo/src/platform/mobile/header/stylePopover/index.module.scss b/demo/src/platform/mobile/header/stylePopover/index.module.scss new file mode 100644 index 00000000..defdcc12 --- /dev/null +++ b/demo/src/platform/mobile/header/stylePopover/index.module.scss @@ -0,0 +1,51 @@ +.info { + padding: 12px 16px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + align-self: stretch; + + + .title { + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + } + + .color { + font-size: 0; + white-space: nowrap; + + .item { + position: relative; + display: inline-block; + width: 28px; + height: 28px; + border-radius: 4px; + border: 2px solid transparent; + font-size: 0; + cursor: pointer; + + .inner { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 18px; + height: 18px; + border-radius: 2px; + box-sizing: border-box; + } + } + + .item+.item { + margin-left: 12px; + } + + } + + +} diff --git a/demo/src/platform/mobile/header/stylePopover/index.tsx b/demo/src/platform/mobile/header/stylePopover/index.tsx new file mode 100644 index 00000000..f8508323 --- /dev/null +++ b/demo/src/platform/mobile/header/stylePopover/index.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react" +import { COLOR_LIST, useAppSelector, useAppDispatch } from "@/common" +import { setThemeColor } from "@/store/reducers/global" +import ColorPicker from "./colorPicker" +import { Popover } from 'antd'; + + +import styles from "./index.module.scss" + +interface StylePopoverProps { + children?: React.ReactNode +} + +const StylePopover = (props: StylePopoverProps) => { + const { children } = props + const dispatch = useAppDispatch() + const themeColor = useAppSelector(state => state.global.themeColor) + + + const onClickColor = (index: number) => { + const target = COLOR_LIST[index] + if (target.active !== themeColor) { + dispatch(setThemeColor(target.active)) + } + } + + const content =
+
STYLE
+
+ { + COLOR_LIST.map((item, index) => { + return onClickColor(index)} + className={styles.item} + key={index}> + + + }) + } +
+ +
+ + + return {children} + +} + +export default StylePopover diff --git a/demo/src/platform/mobile/menu/context.ts b/demo/src/platform/mobile/menu/context.ts new file mode 100644 index 00000000..41c52911 --- /dev/null +++ b/demo/src/platform/mobile/menu/context.ts @@ -0,0 +1,9 @@ +import { createContext } from "react" + +export interface MenuContextType { + scrollToBottom: () => void; +} + +export const MenuContext = createContext({ + scrollToBottom: () => { } +}); diff --git a/demo/src/platform/mobile/menu/index.module.scss b/demo/src/platform/mobile/menu/index.module.scss new file mode 100644 index 00000000..58b1b3fe --- /dev/null +++ b/demo/src/platform/mobile/menu/index.module.scss @@ -0,0 +1,69 @@ +.menu { + width: 100%; + border: 1px solid #272A2F; + border-radius: 4px; + background: #0F0F11; + overflow: hidden; + box-sizing: border-box; + + .header { + height: 40px; + overflow: hidden; + border-bottom: 1px solid #272A2F; + box-sizing: border-box; + + .menuItem { + height: 40px; + padding: 0 16px; + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-weight: 600; + line-height: 40px; + letter-spacing: 0.449px; + display: inline-block; + color: #667085; + background: #181A1E; + cursor: pointer; + border-right: 1px solid #272A2F; + box-sizing: border-box; + overflow: hidden; + background: #0F0F11; + } + + .active { + color: #EAECF0; + background: #181A1D; + } + } + + + .content { + position: relative; + background: #181A1D; + // header 48px + // description 60px + // paddingTop 16px 16px + // menu header 40px + height: calc(100vh - 48px - 60px - 32px - 40px - 2px); + overflow: hidden; + box-sizing: border-box; + + .item { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + padding: 16px; + z-index: -1; + overflow: auto; + visibility: hidden; + box-sizing: border-box; + } + + .active { + z-index: 1; + visibility: visible; + } + } +} diff --git a/demo/src/platform/mobile/menu/index.tsx b/demo/src/platform/mobile/menu/index.tsx new file mode 100644 index 00000000..2e20de78 --- /dev/null +++ b/demo/src/platform/mobile/menu/index.tsx @@ -0,0 +1,76 @@ +"use client" + +import { ReactElement, useEffect, useState, useRef, useMemo, useCallback } from "react" +import { useAutoScroll } from "@/common" +import { MenuContext } from "./context" +import styles from "./index.module.scss" + +export interface IMenuData { + name: string, + component: ReactElement +} + +export interface IMenuContentComponentPros { + scrollToBottom: () => void +} + +interface MenuProps { + data: IMenuData[] +} + + +const Menu = (props: MenuProps) => { + const { data } = props + const [activeIndex, setActiveIndex] = useState(0) + const contentRefList = useRef<(HTMLDivElement | null)[]>([]) + + const onClickItem = (index: number) => { + setActiveIndex(index) + } + + useEffect(() => { + scrollToTop() + }, [activeIndex]) + + const scrollToBottom = useCallback(() => { + const current = contentRefList.current?.[activeIndex] + if (current) { + current.scrollTop = current.scrollHeight + } + }, [contentRefList, activeIndex]) + + const scrollToTop = useCallback(() => { + const current = contentRefList.current?.[activeIndex] + if (current) { + current.scrollTop = 0 + } + }, [contentRefList, activeIndex]) + + + return
+
+ {data.map((item, index) => { + return onClickItem(index)}>{item.name} + })} +
+
+ + {data.map((item, index) => { + return
{ + contentRefList.current[index] = el; + }} + className={`${styles.item} ${index == activeIndex ? styles.active : ''}`}> + {item.component} +
+ })} +
+
+
+} + +export default Menu diff --git a/demo/src/platform/mobile/rtc/agent/index.module.scss b/demo/src/platform/mobile/rtc/agent/index.module.scss new file mode 100644 index 00000000..fa3ae2ec --- /dev/null +++ b/demo/src/platform/mobile/rtc/agent/index.module.scss @@ -0,0 +1,31 @@ +.agent { + position: relative; + display: flex; + height: 292px; + padding: 20px 16px; + flex-direction: column; + justify-content: flex-start; + align-items: center; + align-self: stretch; + background: linear-gradient(154deg, rgba(27, 66, 166, 0.16) 0%, rgba(27, 45, 140, 0.00) 18%), linear-gradient(153deg, rgba(23, 24, 28, 0.00) 53.75%, #11174E 100%), #0F0F11; + box-shadow: 0px 3.999px 48.988px 0px rgba(0, 7, 72, 0.12); + backdrop-filter: blur(7); + box-sizing: border-box; + + .text { + margin-top: 50px; + color: var(--theme-color, #EAECF0); + font-size: 24px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + } + + .view { + margin-top: 32px; + display: flex; + align-items: center; + justify-content: center; + height: 56px; + } +} diff --git a/demo/src/platform/mobile/rtc/agent/index.tsx b/demo/src/platform/mobile/rtc/agent/index.tsx new file mode 100644 index 00000000..a7fd7944 --- /dev/null +++ b/demo/src/platform/mobile/rtc/agent/index.tsx @@ -0,0 +1,34 @@ +"use client" + +import { useAppSelector, useMultibandTrackVolume } from "@/common" +import AudioVisualizer from "../audioVisualizer" +import { IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng'; +import styles from "./index.module.scss" + +interface AgentProps { + audioTrack?: IMicrophoneAudioTrack +} + +const Agent = (props: AgentProps) => { + const { audioTrack } = props + + const subscribedVolumes = useMultibandTrackVolume(audioTrack, 12); + + return
+
Agent
+
+ +
+
+ +} + + +export default Agent; diff --git a/demo/src/platform/mobile/rtc/audioVisualizer/index.module.scss b/demo/src/platform/mobile/rtc/audioVisualizer/index.module.scss new file mode 100644 index 00000000..1beae944 --- /dev/null +++ b/demo/src/platform/mobile/rtc/audioVisualizer/index.module.scss @@ -0,0 +1,17 @@ +.audioVisualizer { + display: flex; + justify-content: center; + align-items: center; + + + .item {} + + .agent { + background-color: var(--theme-color, #EAECF0); + box-shadow: 0 0 10px var(--theme-color, #EAECF0); + } + + .user { + background-color: var(--Grey-300, #EAECF0); + } +} diff --git a/demo/src/platform/mobile/rtc/audioVisualizer/index.tsx b/demo/src/platform/mobile/rtc/audioVisualizer/index.tsx new file mode 100644 index 00000000..bc21f554 --- /dev/null +++ b/demo/src/platform/mobile/rtc/audioVisualizer/index.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useState, useEffect } from "react" +import styles from "./index.module.scss" + +interface AudioVisualizerProps { + type: "agent" | "user"; + frequencies: Float32Array[]; + gap: number; + barWidth: number; + minBarHeight: number; + maxBarHeight: number + borderRadius: number; +} + + +const AudioVisualizer = (props: AudioVisualizerProps) => { + const { frequencies, gap, barWidth, minBarHeight, maxBarHeight, borderRadius, type } = props; + + const summedFrequencies = frequencies.map((bandFrequencies) => { + const sum = bandFrequencies.reduce((a, b) => a + b, 0) + if (sum <= 0) { + return 0 + } + return Math.sqrt(sum / bandFrequencies.length); + }); + + return
{ + summedFrequencies.map((frequency, index) => { + + const style = { + height: minBarHeight + frequency * (maxBarHeight - minBarHeight) + "px", + borderRadius: borderRadius + "px", + width: barWidth + "px", + transition: + "background-color 0.35s ease-out, transform 0.25s ease-out", + // transform: transform, + } + + return + }) + }
+} + + +export default AudioVisualizer; diff --git a/demo/src/platform/mobile/rtc/camSection/camSelect/index.module.scss b/demo/src/platform/mobile/rtc/camSection/camSelect/index.module.scss new file mode 100644 index 00000000..8ca5088b --- /dev/null +++ b/demo/src/platform/mobile/rtc/camSection/camSelect/index.module.scss @@ -0,0 +1,4 @@ +.select { + flex: 0 0 200px; + width: 200px; +} diff --git a/demo/src/platform/mobile/rtc/camSection/camSelect/index.tsx b/demo/src/platform/mobile/rtc/camSection/camSelect/index.tsx new file mode 100644 index 00000000..33a5e003 --- /dev/null +++ b/demo/src/platform/mobile/rtc/camSection/camSelect/index.tsx @@ -0,0 +1,57 @@ +"use client" + +import AgoraRTC, { ICameraVideoTrack } from "agora-rtc-sdk-ng" +import { useState, useEffect } from "react" +import { Select } from "antd" + +import styles from "./index.module.scss" + +interface CamSelectProps { + videoTrack?: ICameraVideoTrack +} + +interface SelectItem { + label: string + value: string + deviceId: string +} + +const DEFAULT_ITEM: SelectItem = { + label: "Default", + value: "default", + deviceId: "" +} + +const CamSelect = (props: CamSelectProps) => { + const { videoTrack } = props + const [items, setItems] = useState([DEFAULT_ITEM]); + const [value, setValue] = useState("default"); + + useEffect(() => { + if (videoTrack) { + const label = videoTrack?.getTrackLabel(); + setValue(label); + AgoraRTC.getCameras().then(arr => { + setItems(arr.map(item => ({ + label: item.label, + value: item.label, + deviceId: item.deviceId + }))); + }); + } + }, [videoTrack]); + + const onChange = async (value: string) => { + const target = items.find(item => item.value === value); + if (target) { + setValue(target.value); + if (videoTrack) { + await videoTrack.setDevice(target.deviceId); + } + } + } + + return +} + +export default CamSelect diff --git a/demo/src/platform/mobile/rtc/camSection/index.module.scss b/demo/src/platform/mobile/rtc/camSection/index.module.scss new file mode 100644 index 00000000..76f4ad1e --- /dev/null +++ b/demo/src/platform/mobile/rtc/camSection/index.module.scss @@ -0,0 +1,54 @@ +.camera { + position: relative; + width: 100%; + height: 100%; + box-sizing: border-box; + + .title { + margin-bottom: 10px; + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-weight: 500; + line-height: 150%; + letter-spacing: 0.449px; + } + + .select { + height: 32px; + display: flex; + width: 100%; + justify-content: flex-start; + align-items: center; + + .iconWrapper { + flex: 0 0 auto; + margin-right: 12px; + display: flex; + width: 32px; + height: 32px; + flex-direction: column; + justify-content: center; + align-items: center; + flex-shrink: 0; + border-radius: 6px; + border: 1px solid #2B2F36; + cursor: pointer; + } + + .select { + flex: 0 0 auto; + width: 200px; + } + } + + .view { + position: relative; + margin-top: 12px; + min-height: 210px; + height: 210px; + border-radius: 6px; + border: 1px solid #272A2F; + background: #1E2024; + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25); + } +} diff --git a/demo/src/platform/mobile/rtc/camSection/index.tsx b/demo/src/platform/mobile/rtc/camSection/index.tsx new file mode 100644 index 00000000..2bd0e8db --- /dev/null +++ b/demo/src/platform/mobile/rtc/camSection/index.tsx @@ -0,0 +1,42 @@ +"use client" + +import CamSelect from "./camSelect" +import { CamIcon } from "@/components/icons" +import styles from "./index.module.scss" +import { ICameraVideoTrack } from 'agora-rtc-sdk-ng'; +import { LocalStreamPlayer } from "../streamPlayer" +import { useState, useEffect, useMemo } from 'react'; +import { useSmallScreen } from "@/common" + +interface CamSectionProps { + videoTrack?: ICameraVideoTrack +} + +const CamSection = (props: CamSectionProps) => { + const { videoTrack } = props + const [videoMute, setVideoMute] = useState(false) + + useEffect(() => { + videoTrack?.setMuted(videoMute) + }, [videoTrack, videoMute]) + + const onClickMute = () => { + setVideoMute(!videoMute) + } + + return
+
CAMERA
+
+ + + + +
+
+ +
+
+} + + +export default CamSection; diff --git a/demo/src/platform/mobile/rtc/index.module.scss b/demo/src/platform/mobile/rtc/index.module.scss new file mode 100644 index 00000000..ff7b7958 --- /dev/null +++ b/demo/src/platform/mobile/rtc/index.module.scss @@ -0,0 +1,55 @@ +.rtc { + flex: 0 0 420px; + display: flex; + flex-direction: column; + align-items: flex-start; + flex-shrink: 0; + align-self: stretch; + border-radius: 8px; + border: 1px solid #272A2F; + background: #181A1D; + box-sizing: border-box; + + .header { + display: flex; + height: 40px; + padding: 0px 16px; + align-items: center; + align-self: stretch; + border-bottom: 1px solid #272A2F; + + .text { + flex: 1 1 auto; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + color: var(--Grey-300, #EAECF0); + } + + .voiceSelect { + flex: 0 0 120px; + } + } + + .you { + display: flex; + padding: 24px 16px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 24px; + align-self: stretch; + border-top: 1px solid #272A2F; + + .title { + color: var(--Grey-300, #EAECF0); + font-size: 24px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + text-align: center; + } + + + } +} diff --git a/demo/src/platform/mobile/rtc/index.tsx b/demo/src/platform/mobile/rtc/index.tsx new file mode 100644 index 00000000..bc15c070 --- /dev/null +++ b/demo/src/platform/mobile/rtc/index.tsx @@ -0,0 +1,128 @@ +"use client" + +import { ICameraVideoTrack, IMicrophoneAudioTrack } from "agora-rtc-sdk-ng" +import { useAppSelector, useAppDispatch, VOICE_OPTIONS } from "@/common" +import { ITextItem } from "@/types" +import { rtcManager, IUserTracks, IRtcUser } from "@/manager" +import { setRoomConnected, addChatItem, setVoiceType } from "@/store/reducers/global" +import MicSection from "./micSection" +import CamSection from "./camSection" +import Agent from "./agent" +import styles from "./index.module.scss" +import { useRef, useEffect, useState, Fragment } from "react" +import { VoiceIcon } from "@/components/icons" +import CustomSelect from "@/components/customSelect" + +let hasInit = false + +const Rtc = () => { + const dispatch = useAppDispatch() + const options = useAppSelector(state => state.global.options) + const voiceType = useAppSelector(state => state.global.voiceType) + const agentConnected = useAppSelector(state => state.global.agentConnected) + const { userId, channel } = options + const [videoTrack, setVideoTrack] = useState() + const [audioTrack, setAudioTrack] = useState() + const [remoteuser, setRemoteUser] = useState() + + useEffect(() => { + if (!options.channel) { + return + } + if (hasInit) { + return + } + + init() + + return () => { + if (hasInit) { + destory() + } + } + }, [options.channel]) + + + const init = async () => { + console.log("[test] init") + rtcManager.on("localTracksChanged", onLocalTracksChanged) + rtcManager.on("textChanged", onTextChanged) + rtcManager.on("remoteUserChanged", onRemoteUserChanged) + await rtcManager.createTracks() + await rtcManager.join({ + channel, + userId + }) + await rtcManager.publish() + dispatch(setRoomConnected(true)) + hasInit = true + } + + const destory = async () => { + console.log("[test] destory") + rtcManager.off("textChanged", onTextChanged) + rtcManager.off("localTracksChanged", onLocalTracksChanged) + rtcManager.off("remoteUserChanged", onRemoteUserChanged) + await rtcManager.destroy() + dispatch(setRoomConnected(false)) + hasInit = false + } + + const onRemoteUserChanged = (user: IRtcUser) => { + console.log("[test] onRemoteUserChanged", user) + setRemoteUser(user) + } + + const onLocalTracksChanged = (tracks: IUserTracks) => { + console.log("[test] onLocalTracksChanged", tracks) + const { videoTrack, audioTrack } = tracks + if (videoTrack) { + setVideoTrack(videoTrack) + } + if (audioTrack) { + setAudioTrack(audioTrack) + } + } + + const onTextChanged = (text: ITextItem) => { + if (text.dataType == "transcribe") { + const isAgent = Number(text.uid) != Number(userId) + dispatch(addChatItem({ + userId: text.uid, + text: text.text, + type: isAgent ? "agent" : "user", + isFinal: text.isFinal, + time: text.time + })) + } + } + + const onVoiceChange = (value: any) => { + dispatch(setVoiceType(value)) + } + + + return
+
+ Audio & Video + } + options={VOICE_OPTIONS} onChange={onVoiceChange}> +
+ {/* agent */} + + {/* you */} +
+
You
+ {/* microphone */} + + {/* camera */} + +
+
+} + + +export default Rtc; diff --git a/demo/src/platform/mobile/rtc/micSection/index.module.scss b/demo/src/platform/mobile/rtc/micSection/index.module.scss new file mode 100644 index 00000000..60cc6fe1 --- /dev/null +++ b/demo/src/platform/mobile/rtc/micSection/index.module.scss @@ -0,0 +1,58 @@ +.microphone { + position: relative; + width: 100%; + height: 100%; + box-sizing: border-box; + + .title { + margin-bottom: 10px; + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-weight: 500; + line-height: 150%; + letter-spacing: 0.449px; + } + + + .select { + height: 32px; + display: flex; + width: 100%; + justify-content: flex-start; + align-items: center; + + + .iconWrapper { + flex: 0 0 auto; + margin-right: 12px; + display: flex; + width: 32px; + height: 32px; + flex-direction: column; + justify-content: center; + align-items: center; + flex-shrink: 0; + border-radius: 6px; + border: 1px solid #2B2F36; + cursor: pointer; + } + + + } + + .view { + margin-top: 12px; + display: flex; + height: 120px; + padding: 24px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + align-self: stretch; + border-radius: 6px; + border: 1px solid #272A2F; + background: #1E2024; + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25); + } +} diff --git a/demo/src/platform/mobile/rtc/micSection/index.tsx b/demo/src/platform/mobile/rtc/micSection/index.tsx new file mode 100644 index 00000000..3c739159 --- /dev/null +++ b/demo/src/platform/mobile/rtc/micSection/index.tsx @@ -0,0 +1,70 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useMultibandTrackVolume, useSmallScreen } from "@/common" +import AudioVisualizer from "../audioVisualizer" +import { MicIcon } from "@/components/icons" +import styles from "./index.module.scss" +import { IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng'; +import MicSelect from "./micSelect"; + +interface MicSectionProps { + audioTrack?: IMicrophoneAudioTrack +} + +const MicSection = (props: MicSectionProps) => { + const { audioTrack } = props + const [audioMute, setAudioMute] = useState(false) + const [mediaStreamTrack, setMediaStreamTrack] = useState() + + + + useEffect(() => { + audioTrack?.on("track-updated", onAudioTrackupdated) + if (audioTrack) { + setMediaStreamTrack(audioTrack.getMediaStreamTrack()) + } + + return () => { + audioTrack?.off("track-updated", onAudioTrackupdated) + } + }, [audioTrack]) + + useEffect(() => { + audioTrack?.setMuted(audioMute) + }, [audioTrack, audioMute]) + + const subscribedVolumes = useMultibandTrackVolume(mediaStreamTrack, 20); + + const onAudioTrackupdated = (track: MediaStreamTrack) => { + console.log("[test] audio track updated", track) + setMediaStreamTrack(track) + } + + const onClickMute = () => { + setAudioMute(!audioMute) + } + + return
+
MICROPHONE
+
+ + + + +
+
+ +
+
+} + + +export default MicSection; diff --git a/demo/src/platform/mobile/rtc/micSection/micSelect/index.module.scss b/demo/src/platform/mobile/rtc/micSection/micSelect/index.module.scss new file mode 100644 index 00000000..8ca5088b --- /dev/null +++ b/demo/src/platform/mobile/rtc/micSection/micSelect/index.module.scss @@ -0,0 +1,4 @@ +.select { + flex: 0 0 200px; + width: 200px; +} diff --git a/demo/src/platform/mobile/rtc/micSection/micSelect/index.tsx b/demo/src/platform/mobile/rtc/micSection/micSelect/index.tsx new file mode 100644 index 00000000..efc842b5 --- /dev/null +++ b/demo/src/platform/mobile/rtc/micSection/micSelect/index.tsx @@ -0,0 +1,58 @@ +"use client" + +import AgoraRTC from "agora-rtc-sdk-ng" +import { useState, useEffect } from "react" +import { Select } from "antd" +import { IMicrophoneAudioTrack } from "agora-rtc-sdk-ng" + +import styles from "./index.module.scss" + +interface MicSelectProps { + audioTrack?: IMicrophoneAudioTrack +} + +interface SelectItem { + label: string + value: string + deviceId: string +} + +const DEFAULT_ITEM: SelectItem = { + label: "Default", + value: "default", + deviceId: "" +} + +const MicSelect = (props: MicSelectProps) => { + const { audioTrack } = props + const [items, setItems] = useState([DEFAULT_ITEM]); + const [value, setValue] = useState("default"); + + useEffect(() => { + if (audioTrack) { + const label = audioTrack?.getTrackLabel(); + setValue(label); + AgoraRTC.getMicrophones().then(arr => { + setItems(arr.map(item => ({ + label: item.label, + value: item.label, + deviceId: item.deviceId + }))); + }); + } + }, [audioTrack]); + + const onChange = async (value: string) => { + const target = items.find(item => item.value === value); + if (target) { + setValue(target.value); + if (audioTrack) { + await audioTrack.setDevice(target.deviceId); + } + } + } + + return +} + +export default MicSelect diff --git a/demo/src/platform/mobile/rtc/streamPlayer/index.module.scss b/demo/src/platform/mobile/rtc/streamPlayer/index.module.scss new file mode 100644 index 00000000..b1c57c10 --- /dev/null +++ b/demo/src/platform/mobile/rtc/streamPlayer/index.module.scss @@ -0,0 +1,6 @@ +.streamPlayer { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} diff --git a/demo/src/platform/mobile/rtc/streamPlayer/index.tsx b/demo/src/platform/mobile/rtc/streamPlayer/index.tsx new file mode 100644 index 00000000..ba78e377 --- /dev/null +++ b/demo/src/platform/mobile/rtc/streamPlayer/index.tsx @@ -0,0 +1 @@ +export * from "./localStreamPlayer" diff --git a/demo/src/platform/mobile/rtc/streamPlayer/localStreamPlayer.tsx b/demo/src/platform/mobile/rtc/streamPlayer/localStreamPlayer.tsx new file mode 100644 index 00000000..e3e7f06a --- /dev/null +++ b/demo/src/platform/mobile/rtc/streamPlayer/localStreamPlayer.tsx @@ -0,0 +1,46 @@ +"use client" + +import { + ICameraVideoTrack, + IMicrophoneAudioTrack, + IRemoteAudioTrack, + IRemoteVideoTrack, + VideoPlayerConfig, +} from "agora-rtc-sdk-ng" +import { useRef, useState, useLayoutEffect, forwardRef, useEffect, useMemo } from "react" + +import styles from "./index.module.scss" + +interface StreamPlayerProps { + videoTrack?: ICameraVideoTrack + audioTrack?: IMicrophoneAudioTrack + style?: React.CSSProperties + fit?: "cover" | "contain" | "fill" + onClick?: () => void + mute?: boolean +} + +export const LocalStreamPlayer = forwardRef((props: StreamPlayerProps, ref) => { + const { videoTrack, audioTrack, mute = false, style = {}, fit = "cover", onClick = () => { } } = props + const vidDiv = useRef(null) + + useLayoutEffect(() => { + const config = { fit } as VideoPlayerConfig + if (mute) { + videoTrack?.stop() + } else { + if (!videoTrack?.isPlaying) { + videoTrack?.play(vidDiv.current!, config) + } + } + + return () => { + videoTrack?.stop() + } + }, [videoTrack, fit, mute]) + + // local audio track need not to be played + // useLayoutEffect(() => {}, [audioTrack, localAudioMute]) + + return
+}) diff --git a/demo/src/platform/pc/chat/chatItem/index.module.scss b/demo/src/platform/pc/chat/chatItem/index.module.scss new file mode 100644 index 00000000..f28ef7ee --- /dev/null +++ b/demo/src/platform/pc/chat/chatItem/index.module.scss @@ -0,0 +1,90 @@ +.agentChatItem { + width: 100%; + display: flex; + justify-content: flex-start; + + .left { + flex: 0 0 auto; + display: flex; + width: 32px; + height: 32px; + padding: 10px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 200px; + background: var(--Grey-700, #475467); + + .userName { + color: var(---white, #FFF); + text-align: center; + font-size: 14px; + font-weight: 500; + line-height: 150%; + } + } + + .right { + margin-left: 12px; + flex: 1 1 auto; + + .userName { + font-size: 14px; + font-weight: 500; + line-height: 20px; + color: var(--theme-color, #667085) !important; + } + + + .agent { + color: var(--theme-color, #EAECF0) !important; + } + + } +} + +.userChatItem { + width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-end; + + .userName { + text-align: right; + color: var(--Grey-600, #667085); + font-weight: 500; + line-height: 20px; + } + + + +} + + +.chatItem { + .text { + max-width: 80%; + width: fit-content; + margin-top: 6px; + color: #FFF; + display: flex; + padding: 8px 14px; + flex-direction: column; + justify-content: center; + align-items: flex-start; + font-size: 14px; + font-weight: 400; + line-height: 21px; + white-space: pre-wrap; + border-radius: 0px 8px 8px 8px; + border: 1px solid #272A2F; + background: #1E2024; + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25); + } +} + +.chatItem+.chatItem { + margin-top: 14px; +} diff --git a/demo/src/platform/pc/chat/chatItem/index.tsx b/demo/src/platform/pc/chat/chatItem/index.tsx new file mode 100644 index 00000000..6364aaea --- /dev/null +++ b/demo/src/platform/pc/chat/chatItem/index.tsx @@ -0,0 +1,51 @@ +import { IChatItem } from "@/types" +import styles from "./index.module.scss" +import { usePrevious } from "@/common" +import { use, useEffect, useMemo, useState } from "react" + +interface ChatItemProps { + data: IChatItem +} + + +const AgentChatItem = (props: ChatItemProps) => { + const { data } = props + const { text } = data + + return
+ + Ag + + +
Agent
+
+ {text} +
+
+
+} + +const UserChatItem = (props: ChatItemProps) => { + const { data } = props + const { text } = data + + return
+
You
+
{text}
+
+} + + +const ChatItem = (props: ChatItemProps) => { + const { data } = props + + + return ( + data.type === "agent" ? : + ); + + +} + + +export default ChatItem diff --git a/demo/src/platform/pc/chat/index.module.scss b/demo/src/platform/pc/chat/index.module.scss new file mode 100644 index 00000000..39c4956d --- /dev/null +++ b/demo/src/platform/pc/chat/index.module.scss @@ -0,0 +1,79 @@ +.chat { + flex: 1 1 auto; + min-width: 500px; + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + border-radius: 8px; + border: 1px solid #272A2F; + background: #181A1D; + overflow: hidden; + + .header { + display: flex; + height: 42px; + padding: 0px 16px; + align-items: center; + align-self: stretch; + border-bottom: 1px solid #272A2F; + + .left { + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 5px; + + .text { + margin-left: 4px; + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-weight: 600; + height: 40px; + line-height: 40px; + letter-spacing: 0.449px; + } + + .languageSelect { + width: 100px; + } + } + + + .right { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 230px; + justify-content: right; + } + + } + + .content { + display: flex; + padding: 12px 24px; + flex-direction: column; + align-items: flex-start; + gap: 10px; + flex: 1 0 500px; + align-self: stretch; + overflow-y: auto; + + + &::-webkit-scrollbar { + width: 6px + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: #6B6B6B; + border-radius: 4px; + } + } + + +} diff --git a/demo/src/platform/pc/chat/index.tsx b/demo/src/platform/pc/chat/index.tsx new file mode 100644 index 00000000..64dea171 --- /dev/null +++ b/demo/src/platform/pc/chat/index.tsx @@ -0,0 +1,66 @@ +"use client" + +import { ReactElement, useEffect, useRef, useState } from "react" +import ChatItem from "./chatItem" +import { + genRandomChatList, useAppDispatch, useAutoScroll, + LANGUAGE_OPTIONS, useAppSelector, + GRAPH_OPTIONS, + isRagGraph, +} from "@/common" +import { setGraphName, setLanguage } from "@/store/reducers/global" +import { Select, } from 'antd'; +import PdfSelect from "@/components/pdfSelect" + +import styles from "./index.module.scss" + + + + +const Chat = () => { + const dispatch = useAppDispatch() + const chatItems = useAppSelector(state => state.global.chatItems) + const language = useAppSelector(state => state.global.language) + const graphName = useAppSelector(state => state.global.graphName) + const agentConnected = useAppSelector(state => state.global.agentConnected) + + // const chatItems = genRandomChatList(10) + const chatRef = useRef(null) + + + useAutoScroll(chatRef) + + + const onLanguageChange = (val: any) => { + dispatch(setLanguage(val)) + } + + const onGraphNameChange = (val: any) => { + dispatch(setGraphName(val)) + } + + + return
+
+ + + + + + {isRagGraph(graphName) ? : null} + +
+
+ {chatItems.map((item, index) => { + return + })} +
+
+} + + +export default Chat diff --git a/demo/src/platform/pc/description/index.module.scss b/demo/src/platform/pc/description/index.module.scss new file mode 100644 index 00000000..50b29301 --- /dev/null +++ b/demo/src/platform/pc/description/index.module.scss @@ -0,0 +1,73 @@ +.description { + position: relative; + display: flex; + padding: 12px 16px; + align-items: center; + gap: 12px; + align-self: stretch; + border-radius: 8px; + border: 1px solid #272A2F; + background: #181A1D; + + .title { + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-style: normal; + font-weight: 600; + /* 21px */ + letter-spacing: 0.449px; + } + + .text { + margin-left: 12px; + flex: 1 1 auto; + color: var(--Grey-600, #667085); + font-size: 14px; + font-style: normal; + font-weight: 400; + } + + + .btnConnect { + width: 150px; + display: flex; + padding: 8px 14px; + justify-content: center; + align-items: center; + gap: 8px; + align-self: stretch; + border-radius: 6px; + background: var(--theme-color, #0888FF); + border: 1px solid var(--theme-color, #0888FF); + box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); + cursor: pointer; + user-select: none; + caret-color: transparent; + box-sizing: border-box; + + .btnText { + width: 100px; + text-align: center; + color: var(---White, #FFF); + font-size: 14px; + font-weight: 500; + line-height: 20px; + } + + .btnText.disconnect { + color: var(--Error-400-T, #E95C7B); + } + + + .loading { + margin-left: 4px; + } + } + + + .btnConnect.disconnect { + background: #181A1D; + border: 1px solid var(--Error-400-T, #E95C7B); + } + +} diff --git a/demo/src/platform/pc/description/index.tsx b/demo/src/platform/pc/description/index.tsx new file mode 100644 index 00000000..a9a055cd --- /dev/null +++ b/demo/src/platform/pc/description/index.tsx @@ -0,0 +1,101 @@ +import { setAgentConnected } from "@/store/reducers/global" +import { + DESCRIPTION, useAppDispatch, useAppSelector, apiPing, genUUID, + apiStartService, apiStopService +} from "@/common" +import { Select, Button, message, Upload } from "antd" +import { useEffect, useState, MouseEventHandler } from "react" +import { LoadingOutlined, UploadOutlined } from "@ant-design/icons" +import styles from "./index.module.scss" + +let intervalId: any + +const Description = () => { + const dispatch = useAppDispatch() + const agentConnected = useAppSelector(state => state.global.agentConnected) + const channel = useAppSelector(state => state.global.options.channel) + const userId = useAppSelector(state => state.global.options.userId) + const language = useAppSelector(state => state.global.language) + const voiceType = useAppSelector(state => state.global.voiceType) + const graphName = useAppSelector(state => state.global.graphName) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (channel) { + checkAgentConnected() + } + }, [channel]) + + + const checkAgentConnected = async () => { + const res: any = await apiPing(channel) + if (res?.code == 0) { + dispatch(setAgentConnected(true)) + } + } + + const onClickConnect = async () => { + if (loading) { + return + } + setLoading(true) + if (agentConnected) { + await apiStopService(channel) + dispatch(setAgentConnected(false)) + message.success("Agent disconnected") + stopPing() + } else { + const res = await apiStartService({ + channel, + userId, + graphName, + language, + voiceType + }) + const { code, msg } = res || {} + if (code != 0) { + if (code == "10001") { + message.error("The number of users experiencing the program simultaneously has exceeded the limit. Please try again later.") + } else { + message.error(`code:${code},msg:${msg}`) + } + setLoading(false) + throw new Error(msg) + } + dispatch(setAgentConnected(true)) + message.success("Agent connected") + startPing() + } + setLoading(false) + } + + const startPing = () => { + if (intervalId) { + stopPing() + } + intervalId = setInterval(() => { + apiPing(channel) + }, 3000) + } + + const stopPing = () => { + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + + return
+ Description + Astra is a multimodal agent powered by TEN + + + {!agentConnected ? "Connect" : "Disconnect"} + {loading ? : null} + + +
+} + + +export default Description diff --git a/demo/src/platform/pc/entry/index.module.scss b/demo/src/platform/pc/entry/index.module.scss new file mode 100644 index 00000000..5f161d2c --- /dev/null +++ b/demo/src/platform/pc/entry/index.module.scss @@ -0,0 +1,27 @@ +.entry { + position: relative; + height: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + + .content { + position: relative; + padding: 16px; + box-sizing: border-box; + display: flex; + height: calc(100% - 64px); + flex-direction: column; + + .body { + margin-top: 16px; + display: flex; + gap: 24px; + flex-grow: 1; + .chat { + display: flex; + flex-grow: 1; + } + } + } +} diff --git a/demo/src/platform/pc/entry/index.tsx b/demo/src/platform/pc/entry/index.tsx new file mode 100644 index 00000000..a7ee7592 --- /dev/null +++ b/demo/src/platform/pc/entry/index.tsx @@ -0,0 +1,26 @@ +import Chat from "../chat" +import Description from "../description" +import Rtc from "../rtc" +import Header from "../header" + +import styles from "./index.module.scss" + +const PCEntry = () => { + return
+
+
+ +
+
+ +
+
+ +
+
+
+
+} + + +export default PCEntry diff --git a/demo/src/platform/pc/header/index.module.scss b/demo/src/platform/pc/header/index.module.scss new file mode 100644 index 00000000..31138cc7 --- /dev/null +++ b/demo/src/platform/pc/header/index.module.scss @@ -0,0 +1,58 @@ +.header { + display: flex; + width: 100%; + height: 48px; + padding: 24px; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #24262A; + background: #1E2024; + box-shadow: 0px 12px 16px -4px rgba(8, 15, 52, 0.06), 0px 4px 6px -2px rgba(8, 15, 52, 0.03); + box-sizing: border-box; + z-index: 999; + + .logoWrapper { + display: flex; + align-items: center; + + .text { + margin-left: 8px; + color: var(---white, #FFF); + text-align: right; + font-family: Inter; + font-size: 16px; + font-weight: 500; + } + } + + .content { + display: flex; + align-items: center; + justify-content: center; + height: 48px; + flex: 1 1 auto; + color: var(--Grey-300, #EAECF0); + font-size: 16px; + font-weight: 500; + line-height: 48px; + letter-spacing: 0.449px; + text-align: center; + + .text { + margin-left: 4px; + } + } + + .links { + display: flex; + align-items: center; + gap: 8px; + + span { + display: flex; + } + } + .githubWrapper { + cursor: pointer; + } +} diff --git a/demo/src/platform/pc/header/index.tsx b/demo/src/platform/pc/header/index.tsx new file mode 100644 index 00000000..a55b3f04 --- /dev/null +++ b/demo/src/platform/pc/header/index.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useAppSelector, GITHUB_URL, useSmallScreen } from "@/common" +import Network from "./network" +import InfoPopover from "./infoPopover" +import StylePopover from "./stylePopover" +import { GithubIcon, LogoIcon, InfoIcon, ColorPickerIcon } from "@/components/icons" + +import styles from "./index.module.scss" + +const Header = () => { + const themeColor = useAppSelector(state => state.global.themeColor) + const options = useAppSelector(state => state.global.options) + const { channel } = options + + + const onClickGithub = () => { + if (typeof window !== "undefined") { + window.open(GITHUB_URL, "_blank") + } + } + + + + return
+ + + + + + + Channel Name: {channel} + + +
+ + + + + + + +
+
+} + + +export default Header diff --git a/demo/src/platform/pc/header/infoPopover/index.module.scss b/demo/src/platform/pc/header/infoPopover/index.module.scss new file mode 100644 index 00000000..cd3f72f8 --- /dev/null +++ b/demo/src/platform/pc/header/infoPopover/index.module.scss @@ -0,0 +1,43 @@ +.info { + display: flex; + padding: 12px 16px; + flex-direction: column; + align-items: flex-start; + gap: 8px; + align-self: stretch; + + .title { + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + } + + .item { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + + .title { + color: var(--Grey-600, #667085); + font-size: 14px; + font-weight: 400; + line-height: 150%; + } + + .content { + color: var(--theme-color, #FFF); + font-size: 14px; + font-weight: 400; + line-height: 150%; + } + } + + .slider { + height: 1px; + width: 100%; + background-color: #0D0F12; + } +} diff --git a/demo/src/platform/pc/header/infoPopover/index.tsx b/demo/src/platform/pc/header/infoPopover/index.tsx new file mode 100644 index 00000000..cd451418 --- /dev/null +++ b/demo/src/platform/pc/header/infoPopover/index.tsx @@ -0,0 +1,57 @@ +import { useMemo } from "react" +import { useAppSelector } from "@/common" +import { Popover } from 'antd'; + + +import styles from "./index.module.scss" + +interface InfoPopoverProps { + children?: React.ReactNode +} + +const InfoPopover = (props: InfoPopoverProps) => { + const { children } = props + const options = useAppSelector(state => state.global.options) + const { channel, userId } = options + + const roomConnected = useAppSelector(state => state.global.roomConnected) + const agentConnected = useAppSelector(state => state.global.agentConnected) + + const roomConnectedText = useMemo(() => { + return roomConnected ? "TRUE" : "FALSE" + }, [roomConnected]) + + const agentConnectedText = useMemo(() => { + return agentConnected ? "TRUE" : "FALSE" + }, [agentConnected]) + + + + const content =
+
INFO
+
+ Room + {channel} +
+
+ Participant + {userId} +
+
+
STATUS
+
+
Room connected
+
{roomConnectedText}
+
+
+
Agent connected
+
{agentConnectedText}
+
+
+ + + return {children} + +} + +export default InfoPopover diff --git a/demo/src/platform/pc/header/network/index.module.scss b/demo/src/platform/pc/header/network/index.module.scss new file mode 100644 index 00000000..e69de29b diff --git a/demo/src/platform/pc/header/network/index.tsx b/demo/src/platform/pc/header/network/index.tsx new file mode 100644 index 00000000..92b4e33b --- /dev/null +++ b/demo/src/platform/pc/header/network/index.tsx @@ -0,0 +1,37 @@ +"use client"; + +import React from "react"; +import { rtcManager } from "@/manager" +import { NetworkQuality } from "agora-rtc-sdk-ng" +import { useEffect, useState } from "react" +import { NetworkIcon } from "@/components/icons" + +interface NetworkProps { + style?: React.CSSProperties +} + +const NetWork = (props: NetworkProps) => { + const { style } = props + + const [networkQuality, setNetworkQuality] = useState() + + useEffect(() => { + rtcManager.on("networkQuality", onNetworkQuality) + + return () => { + rtcManager.off("networkQuality", onNetworkQuality) + } + }, []) + + const onNetworkQuality = (quality: NetworkQuality) => { + setNetworkQuality(quality) + } + + return ( + + + + ) +} + +export default NetWork diff --git a/demo/src/platform/pc/header/stylePopover/colorPicker/index.module.scss b/demo/src/platform/pc/header/stylePopover/colorPicker/index.module.scss new file mode 100644 index 00000000..405e7781 --- /dev/null +++ b/demo/src/platform/pc/header/stylePopover/colorPicker/index.module.scss @@ -0,0 +1,24 @@ +.colorPicker { + height: 24px; + display: flex; + align-items: center; + + :global(.react-colorful) { + width: 220px; + height: 8px; + } + + :global(.react-colorful__saturation) { + display: none; + } + + :global(.react-colorful__hue) { + border-radius: 8px !important; + height: 8px; + } + + :global(.react-colorful__pointer) { + width: 24px; + height: 24px; + } +} diff --git a/demo/src/platform/pc/header/stylePopover/colorPicker/index.tsx b/demo/src/platform/pc/header/stylePopover/colorPicker/index.tsx new file mode 100644 index 00000000..28163d77 --- /dev/null +++ b/demo/src/platform/pc/header/stylePopover/colorPicker/index.tsx @@ -0,0 +1,22 @@ +"use client" + +import { HexColorPicker } from "react-colorful"; +import { useAppSelector, useAppDispatch } from "@/common" +import { setThemeColor } from "@/store/reducers/global" +import styles from "./index.module.scss"; + +const ColorPicker = () => { + const dispatch = useAppDispatch() + const themeColor = useAppSelector(state => state.global.themeColor) + + const onColorChange = (color: string) => { + console.log(color); + dispatch(setThemeColor(color)) + }; + + return
+ +
+}; + +export default ColorPicker; diff --git a/demo/src/platform/pc/header/stylePopover/index.module.scss b/demo/src/platform/pc/header/stylePopover/index.module.scss new file mode 100644 index 00000000..98c7f182 --- /dev/null +++ b/demo/src/platform/pc/header/stylePopover/index.module.scss @@ -0,0 +1,51 @@ +.info { + display: flex; + padding: 12px 16px; + flex-direction: column; + align-items: flex-start; + gap: 16px; + align-self: stretch; + + + .title { + color: var(--Grey-300, #EAECF0); + font-size: 14px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + } + + .color { + font-size: 0; + white-space: nowrap; + + .item { + position: relative; + display: inline-block; + width: 28px; + height: 28px; + border-radius: 4px; + border: 2px solid transparent; + font-size: 0; + cursor: pointer; + + .inner { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 18px; + height: 18px; + border-radius: 2px; + box-sizing: border-box; + } + } + + .item+.item { + margin-left: 12px; + } + + } + + +} diff --git a/demo/src/platform/pc/header/stylePopover/index.tsx b/demo/src/platform/pc/header/stylePopover/index.tsx new file mode 100644 index 00000000..f8508323 --- /dev/null +++ b/demo/src/platform/pc/header/stylePopover/index.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react" +import { COLOR_LIST, useAppSelector, useAppDispatch } from "@/common" +import { setThemeColor } from "@/store/reducers/global" +import ColorPicker from "./colorPicker" +import { Popover } from 'antd'; + + +import styles from "./index.module.scss" + +interface StylePopoverProps { + children?: React.ReactNode +} + +const StylePopover = (props: StylePopoverProps) => { + const { children } = props + const dispatch = useAppDispatch() + const themeColor = useAppSelector(state => state.global.themeColor) + + + const onClickColor = (index: number) => { + const target = COLOR_LIST[index] + if (target.active !== themeColor) { + dispatch(setThemeColor(target.active)) + } + } + + const content =
+
STYLE
+
+ { + COLOR_LIST.map((item, index) => { + return onClickColor(index)} + className={styles.item} + key={index}> + + + }) + } +
+ +
+ + + return {children} + +} + +export default StylePopover diff --git a/demo/src/platform/pc/rtc/agent/index.module.scss b/demo/src/platform/pc/rtc/agent/index.module.scss new file mode 100644 index 00000000..fa3ae2ec --- /dev/null +++ b/demo/src/platform/pc/rtc/agent/index.module.scss @@ -0,0 +1,31 @@ +.agent { + position: relative; + display: flex; + height: 292px; + padding: 20px 16px; + flex-direction: column; + justify-content: flex-start; + align-items: center; + align-self: stretch; + background: linear-gradient(154deg, rgba(27, 66, 166, 0.16) 0%, rgba(27, 45, 140, 0.00) 18%), linear-gradient(153deg, rgba(23, 24, 28, 0.00) 53.75%, #11174E 100%), #0F0F11; + box-shadow: 0px 3.999px 48.988px 0px rgba(0, 7, 72, 0.12); + backdrop-filter: blur(7); + box-sizing: border-box; + + .text { + margin-top: 50px; + color: var(--theme-color, #EAECF0); + font-size: 24px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + } + + .view { + margin-top: 32px; + display: flex; + align-items: center; + justify-content: center; + height: 56px; + } +} diff --git a/demo/src/platform/pc/rtc/agent/index.tsx b/demo/src/platform/pc/rtc/agent/index.tsx new file mode 100644 index 00000000..a7fd7944 --- /dev/null +++ b/demo/src/platform/pc/rtc/agent/index.tsx @@ -0,0 +1,34 @@ +"use client" + +import { useAppSelector, useMultibandTrackVolume } from "@/common" +import AudioVisualizer from "../audioVisualizer" +import { IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng'; +import styles from "./index.module.scss" + +interface AgentProps { + audioTrack?: IMicrophoneAudioTrack +} + +const Agent = (props: AgentProps) => { + const { audioTrack } = props + + const subscribedVolumes = useMultibandTrackVolume(audioTrack, 12); + + return
+
Agent
+
+ +
+
+ +} + + +export default Agent; diff --git a/demo/src/platform/pc/rtc/audioVisualizer/index.module.scss b/demo/src/platform/pc/rtc/audioVisualizer/index.module.scss new file mode 100644 index 00000000..1beae944 --- /dev/null +++ b/demo/src/platform/pc/rtc/audioVisualizer/index.module.scss @@ -0,0 +1,17 @@ +.audioVisualizer { + display: flex; + justify-content: center; + align-items: center; + + + .item {} + + .agent { + background-color: var(--theme-color, #EAECF0); + box-shadow: 0 0 10px var(--theme-color, #EAECF0); + } + + .user { + background-color: var(--Grey-300, #EAECF0); + } +} diff --git a/demo/src/platform/pc/rtc/audioVisualizer/index.tsx b/demo/src/platform/pc/rtc/audioVisualizer/index.tsx new file mode 100644 index 00000000..bc21f554 --- /dev/null +++ b/demo/src/platform/pc/rtc/audioVisualizer/index.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useState, useEffect } from "react" +import styles from "./index.module.scss" + +interface AudioVisualizerProps { + type: "agent" | "user"; + frequencies: Float32Array[]; + gap: number; + barWidth: number; + minBarHeight: number; + maxBarHeight: number + borderRadius: number; +} + + +const AudioVisualizer = (props: AudioVisualizerProps) => { + const { frequencies, gap, barWidth, minBarHeight, maxBarHeight, borderRadius, type } = props; + + const summedFrequencies = frequencies.map((bandFrequencies) => { + const sum = bandFrequencies.reduce((a, b) => a + b, 0) + if (sum <= 0) { + return 0 + } + return Math.sqrt(sum / bandFrequencies.length); + }); + + return
{ + summedFrequencies.map((frequency, index) => { + + const style = { + height: minBarHeight + frequency * (maxBarHeight - minBarHeight) + "px", + borderRadius: borderRadius + "px", + width: barWidth + "px", + transition: + "background-color 0.35s ease-out, transform 0.25s ease-out", + // transform: transform, + } + + return + }) + }
+} + + +export default AudioVisualizer; diff --git a/demo/src/platform/pc/rtc/camSection/camSelect/index.module.scss b/demo/src/platform/pc/rtc/camSection/camSelect/index.module.scss new file mode 100644 index 00000000..8ca5088b --- /dev/null +++ b/demo/src/platform/pc/rtc/camSection/camSelect/index.module.scss @@ -0,0 +1,4 @@ +.select { + flex: 0 0 200px; + width: 200px; +} diff --git a/demo/src/platform/pc/rtc/camSection/camSelect/index.tsx b/demo/src/platform/pc/rtc/camSection/camSelect/index.tsx new file mode 100644 index 00000000..33a5e003 --- /dev/null +++ b/demo/src/platform/pc/rtc/camSection/camSelect/index.tsx @@ -0,0 +1,57 @@ +"use client" + +import AgoraRTC, { ICameraVideoTrack } from "agora-rtc-sdk-ng" +import { useState, useEffect } from "react" +import { Select } from "antd" + +import styles from "./index.module.scss" + +interface CamSelectProps { + videoTrack?: ICameraVideoTrack +} + +interface SelectItem { + label: string + value: string + deviceId: string +} + +const DEFAULT_ITEM: SelectItem = { + label: "Default", + value: "default", + deviceId: "" +} + +const CamSelect = (props: CamSelectProps) => { + const { videoTrack } = props + const [items, setItems] = useState([DEFAULT_ITEM]); + const [value, setValue] = useState("default"); + + useEffect(() => { + if (videoTrack) { + const label = videoTrack?.getTrackLabel(); + setValue(label); + AgoraRTC.getCameras().then(arr => { + setItems(arr.map(item => ({ + label: item.label, + value: item.label, + deviceId: item.deviceId + }))); + }); + } + }, [videoTrack]); + + const onChange = async (value: string) => { + const target = items.find(item => item.value === value); + if (target) { + setValue(target.value); + if (videoTrack) { + await videoTrack.setDevice(target.deviceId); + } + } + } + + return +} + +export default CamSelect diff --git a/demo/src/platform/pc/rtc/camSection/index.module.scss b/demo/src/platform/pc/rtc/camSection/index.module.scss new file mode 100644 index 00000000..28b88e2e --- /dev/null +++ b/demo/src/platform/pc/rtc/camSection/index.module.scss @@ -0,0 +1,54 @@ +.camera { + position: relative; + width: 100%; + height: 100%; + box-sizing: border-box; + + .select { + height: 32px; + display: flex; + width: 100%; + justify-content: flex-start; + align-items: center; + + .text { + flex: 1 1 auto; + height: 32px; + line-height: 32px; + color: var(--Grey-300, #EAECF0); + font-weight: 500; + letter-spacing: 0.449px; + } + + .iconWrapper { + flex: 0 0 auto; + margin-right: 12px; + display: flex; + width: 32px; + height: 32px; + flex-direction: column; + justify-content: center; + align-items: center; + flex-shrink: 0; + border-radius: 6px; + border: 1px solid #2B2F36; + cursor: pointer; + } + + .select { + flex: 0 0 auto; + width: 200px; + } + } + + .view { + position: relative; + margin-top: 12px; + min-height: 210px; + height: 210px; + border-radius: 6px; + border: 1px solid #272A2F; + background: #1E2024; + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25); + } +} diff --git a/demo/src/platform/pc/rtc/camSection/index.tsx b/demo/src/platform/pc/rtc/camSection/index.tsx new file mode 100644 index 00000000..99e5392c --- /dev/null +++ b/demo/src/platform/pc/rtc/camSection/index.tsx @@ -0,0 +1,47 @@ +"use client" + +import CamSelect from "./camSelect" +import { CamIcon } from "@/components/icons" +import styles from "./index.module.scss" +import { ICameraVideoTrack } from 'agora-rtc-sdk-ng'; +import { LocalStreamPlayer } from "../streamPlayer" +import { useState, useEffect, useMemo } from 'react'; +import { useSmallScreen } from "@/common" + +interface CamSectionProps { + videoTrack?: ICameraVideoTrack +} + +const CamSection = (props: CamSectionProps) => { + const { videoTrack } = props + const [videoMute, setVideoMute] = useState(false) + const { xs } = useSmallScreen() + + const CamText = useMemo(() => { + return xs ? "CAM" : "CAMERA" + }, [xs]) + + useEffect(() => { + videoTrack?.setMuted(videoMute) + }, [videoTrack, videoMute]) + + const onClickMute = () => { + setVideoMute(!videoMute) + } + + return
+
+ {CamText} + + + + +
+
+ +
+
+} + + +export default CamSection; diff --git a/demo/src/platform/pc/rtc/index.module.scss b/demo/src/platform/pc/rtc/index.module.scss new file mode 100644 index 00000000..b62025c5 --- /dev/null +++ b/demo/src/platform/pc/rtc/index.module.scss @@ -0,0 +1,55 @@ +.rtc { + flex: 0 0 420px; + display: flex; + flex-direction: column; + align-items: flex-start; + flex-shrink: 0; + align-self: stretch; + border-radius: 8px; + border: 1px solid #272A2F; + background: #181A1D; + box-sizing: border-box; + + .header { + display: flex; + height: 42px; + padding: 0px 16px; + align-items: center; + align-self: stretch; + border-bottom: 1px solid #272A2F; + + .text { + flex: 1 1 auto; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + color: var(--Grey-300, #EAECF0); + } + + .voiceSelect { + flex: 0 0 120px; + } + } + + .you { + display: flex; + padding: 24px 16px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 24px; + align-self: stretch; + border-top: 1px solid #272A2F; + + .title { + color: var(--Grey-300, #EAECF0); + font-size: 24px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.449px; + text-align: center; + } + + + } +} diff --git a/demo/src/platform/pc/rtc/index.tsx b/demo/src/platform/pc/rtc/index.tsx new file mode 100644 index 00000000..1195ca0f --- /dev/null +++ b/demo/src/platform/pc/rtc/index.tsx @@ -0,0 +1,128 @@ +"use client" + +import { ICameraVideoTrack, IMicrophoneAudioTrack } from "agora-rtc-sdk-ng" +import { useAppSelector, useAppDispatch, VOICE_OPTIONS } from "@/common" +import { ITextItem } from "@/types" +import { rtcManager, IUserTracks, IRtcUser } from "@/manager" +import { setRoomConnected, addChatItem, setVoiceType } from "@/store/reducers/global" +import MicSection from "./micSection" +import CamSection from "./camSection" +import Agent from "./agent" +import styles from "./index.module.scss" +import { useRef, useEffect, useState, Fragment } from "react" +import { VoiceIcon } from "@/components/icons" +import CustomSelect from "@/components/customSelect" + +let hasInit = false + +const Rtc = () => { + const dispatch = useAppDispatch() + const options = useAppSelector(state => state.global.options) + const voiceType = useAppSelector(state => state.global.voiceType) + const agentConnected = useAppSelector(state => state.global.agentConnected) + const { userId, channel } = options + const [videoTrack, setVideoTrack] = useState() + const [audioTrack, setAudioTrack] = useState() + const [remoteuser, setRemoteUser] = useState() + + useEffect(() => { + if (!options.channel) { + return + } + if (hasInit) { + return + } + + init() + + return () => { + if (hasInit) { + destory() + } + } + }, [options.channel]) + + + const init = async () => { + console.log("[test] init") + rtcManager.on("localTracksChanged", onLocalTracksChanged) + rtcManager.on("textChanged", onTextChanged) + rtcManager.on("remoteUserChanged", onRemoteUserChanged) + await rtcManager.createTracks() + await rtcManager.join({ + channel, + userId + }) + await rtcManager.publish() + dispatch(setRoomConnected(true)) + hasInit = true + } + + const destory = async () => { + console.log("[test] destory") + rtcManager.off("textChanged", onTextChanged) + rtcManager.off("localTracksChanged", onLocalTracksChanged) + rtcManager.off("remoteUserChanged", onRemoteUserChanged) + await rtcManager.destroy() + dispatch(setRoomConnected(false)) + hasInit = false + } + + const onRemoteUserChanged = (user: IRtcUser) => { + console.log("[test] onRemoteUserChanged", user) + setRemoteUser(user) + } + + const onLocalTracksChanged = (tracks: IUserTracks) => { + console.log("[test] onLocalTracksChanged", tracks) + const { videoTrack, audioTrack } = tracks + if (videoTrack) { + setVideoTrack(videoTrack) + } + if (audioTrack) { + setAudioTrack(audioTrack) + } + } + + const onTextChanged = (text: ITextItem) => { + if (text.dataType == "transcribe") { + const isAgent = Number(text.uid) != Number(userId) + dispatch(addChatItem({ + userId: text.uid, + text: text.text, + type: isAgent ? "agent" : "user", + isFinal: text.isFinal, + time: text.time + })) + } + } + + const onVoiceChange = (value: any) => { + dispatch(setVoiceType(value)) + } + + + return
+
+ Audio & Video + } + options={VOICE_OPTIONS} onChange={onVoiceChange}> +
+ {/* agent */} + + {/* you */} +
+
You
+ {/* microphone */} + + {/* camera */} + +
+
+} + + +export default Rtc; diff --git a/demo/src/platform/pc/rtc/micSection/index.module.scss b/demo/src/platform/pc/rtc/micSection/index.module.scss new file mode 100644 index 00000000..81fffd3d --- /dev/null +++ b/demo/src/platform/pc/rtc/micSection/index.module.scss @@ -0,0 +1,56 @@ +.microphone { + position: relative; + width: 100%; + height: 100%; + box-sizing: border-box; + + .select { + height: 32px; + display: flex; + width: 100%; + justify-content: flex-start; + align-items: center; + + .text { + flex: 1 1 auto; + height: 32px; + line-height: 32px; + color: var(--Grey-300, #EAECF0); + font-weight: 500; + letter-spacing: 0.449px; + } + + .iconWrapper { + flex: 0 0 auto; + margin-right: 12px; + display: flex; + width: 32px; + height: 32px; + flex-direction: column; + justify-content: center; + align-items: center; + flex-shrink: 0; + border-radius: 6px; + border: 1px solid #2B2F36; + cursor: pointer; + } + + + } + + .view { + margin-top: 12px; + display: flex; + height: 120px; + padding: 24px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + align-self: stretch; + border-radius: 6px; + border: 1px solid #272A2F; + background: #1E2024; + box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.25); + } +} diff --git a/demo/src/platform/pc/rtc/micSection/index.tsx b/demo/src/platform/pc/rtc/micSection/index.tsx new file mode 100644 index 00000000..6d97f3e2 --- /dev/null +++ b/demo/src/platform/pc/rtc/micSection/index.tsx @@ -0,0 +1,73 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useMultibandTrackVolume, useSmallScreen } from "@/common" +import AudioVisualizer from "../audioVisualizer" +import { MicIcon } from "@/components/icons" +import styles from "./index.module.scss" +import { IMicrophoneAudioTrack } from 'agora-rtc-sdk-ng'; +import MicSelect from "./micSelect"; + +interface MicSectionProps { + audioTrack?: IMicrophoneAudioTrack +} + +const MicSection = (props: MicSectionProps) => { + const { audioTrack } = props + const [audioMute, setAudioMute] = useState(false) + const [mediaStreamTrack, setMediaStreamTrack] = useState() + const { xs } = useSmallScreen() + + const MicText = useMemo(() => { + return xs ? "MIC" : "MICROPHONE" + }, [xs]) + + useEffect(() => { + audioTrack?.on("track-updated", onAudioTrackupdated) + if (audioTrack) { + setMediaStreamTrack(audioTrack.getMediaStreamTrack()) + } + + return () => { + audioTrack?.off("track-updated", onAudioTrackupdated) + } + }, [audioTrack]) + + useEffect(() => { + audioTrack?.setMuted(audioMute) + }, [audioTrack, audioMute]) + + const subscribedVolumes = useMultibandTrackVolume(mediaStreamTrack, 20); + + const onAudioTrackupdated = (track: MediaStreamTrack) => { + console.log("[test] audio track updated", track) + setMediaStreamTrack(track) + } + + const onClickMute = () => { + setAudioMute(!audioMute) + } + + return
+
+ {MicText} + + + + +
+
+ +
+
+} + + +export default MicSection; diff --git a/demo/src/platform/pc/rtc/micSection/micSelect/index.module.scss b/demo/src/platform/pc/rtc/micSection/micSelect/index.module.scss new file mode 100644 index 00000000..8ca5088b --- /dev/null +++ b/demo/src/platform/pc/rtc/micSection/micSelect/index.module.scss @@ -0,0 +1,4 @@ +.select { + flex: 0 0 200px; + width: 200px; +} diff --git a/demo/src/platform/pc/rtc/micSection/micSelect/index.tsx b/demo/src/platform/pc/rtc/micSection/micSelect/index.tsx new file mode 100644 index 00000000..efc842b5 --- /dev/null +++ b/demo/src/platform/pc/rtc/micSection/micSelect/index.tsx @@ -0,0 +1,58 @@ +"use client" + +import AgoraRTC from "agora-rtc-sdk-ng" +import { useState, useEffect } from "react" +import { Select } from "antd" +import { IMicrophoneAudioTrack } from "agora-rtc-sdk-ng" + +import styles from "./index.module.scss" + +interface MicSelectProps { + audioTrack?: IMicrophoneAudioTrack +} + +interface SelectItem { + label: string + value: string + deviceId: string +} + +const DEFAULT_ITEM: SelectItem = { + label: "Default", + value: "default", + deviceId: "" +} + +const MicSelect = (props: MicSelectProps) => { + const { audioTrack } = props + const [items, setItems] = useState([DEFAULT_ITEM]); + const [value, setValue] = useState("default"); + + useEffect(() => { + if (audioTrack) { + const label = audioTrack?.getTrackLabel(); + setValue(label); + AgoraRTC.getMicrophones().then(arr => { + setItems(arr.map(item => ({ + label: item.label, + value: item.label, + deviceId: item.deviceId + }))); + }); + } + }, [audioTrack]); + + const onChange = async (value: string) => { + const target = items.find(item => item.value === value); + if (target) { + setValue(target.value); + if (audioTrack) { + await audioTrack.setDevice(target.deviceId); + } + } + } + + return +} + +export default MicSelect diff --git a/demo/src/platform/pc/rtc/streamPlayer/index.module.scss b/demo/src/platform/pc/rtc/streamPlayer/index.module.scss new file mode 100644 index 00000000..b1c57c10 --- /dev/null +++ b/demo/src/platform/pc/rtc/streamPlayer/index.module.scss @@ -0,0 +1,6 @@ +.streamPlayer { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} diff --git a/demo/src/platform/pc/rtc/streamPlayer/index.tsx b/demo/src/platform/pc/rtc/streamPlayer/index.tsx new file mode 100644 index 00000000..ba78e377 --- /dev/null +++ b/demo/src/platform/pc/rtc/streamPlayer/index.tsx @@ -0,0 +1 @@ +export * from "./localStreamPlayer" diff --git a/demo/src/platform/pc/rtc/streamPlayer/localStreamPlayer.tsx b/demo/src/platform/pc/rtc/streamPlayer/localStreamPlayer.tsx new file mode 100644 index 00000000..e3e7f06a --- /dev/null +++ b/demo/src/platform/pc/rtc/streamPlayer/localStreamPlayer.tsx @@ -0,0 +1,46 @@ +"use client" + +import { + ICameraVideoTrack, + IMicrophoneAudioTrack, + IRemoteAudioTrack, + IRemoteVideoTrack, + VideoPlayerConfig, +} from "agora-rtc-sdk-ng" +import { useRef, useState, useLayoutEffect, forwardRef, useEffect, useMemo } from "react" + +import styles from "./index.module.scss" + +interface StreamPlayerProps { + videoTrack?: ICameraVideoTrack + audioTrack?: IMicrophoneAudioTrack + style?: React.CSSProperties + fit?: "cover" | "contain" | "fill" + onClick?: () => void + mute?: boolean +} + +export const LocalStreamPlayer = forwardRef((props: StreamPlayerProps, ref) => { + const { videoTrack, audioTrack, mute = false, style = {}, fit = "cover", onClick = () => { } } = props + const vidDiv = useRef(null) + + useLayoutEffect(() => { + const config = { fit } as VideoPlayerConfig + if (mute) { + videoTrack?.stop() + } else { + if (!videoTrack?.isPlaying) { + videoTrack?.play(vidDiv.current!, config) + } + } + + return () => { + videoTrack?.stop() + } + }, [videoTrack, fit, mute]) + + // local audio track need not to be played + // useLayoutEffect(() => {}, [audioTrack, localAudioMute]) + + return
+}) diff --git a/demo/src/protobuf/SttMessage.js b/demo/src/protobuf/SttMessage.js new file mode 100644 index 00000000..e69de29b diff --git a/demo/src/protobuf/SttMessage.proto b/demo/src/protobuf/SttMessage.proto new file mode 100644 index 00000000..d8e07a54 --- /dev/null +++ b/demo/src/protobuf/SttMessage.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +package Agora.SpeechToText; + +option objc_class_prefix = "Stt"; + +option csharp_namespace = "AgoraSTTSample.Protobuf"; + +option java_package = "io.agora.rtc.speech2text"; +option java_outer_classname = "AgoraSpeech2TextProtobuffer"; + +message Text { + int32 vendor = 1; + int32 version = 2; + int32 seqnum = 3; + int64 uid = 4; + int32 flag = 5; + int64 time = 6; + int32 lang = 7; + int32 starttime = 8; + int32 offtime = 9; + repeated Word words = 10; + bool end_of_segment = 11; + int32 duration_ms = 12; + string data_type = 13; // transcribe ,translate + repeated Translation trans = 14; + string culture = 15; +} +message Word { + string text = 1; + int32 start_ms = 2; + int32 duration_ms = 3; + bool is_final = 4; + double confidence = 5; +} +message Translation { + bool is_final = 1; + string lang = 2; // 翻译语言 + repeated string texts = 3; +} diff --git a/demo/src/protobuf/SttMessage_es6.js b/demo/src/protobuf/SttMessage_es6.js new file mode 100644 index 00000000..54188af8 --- /dev/null +++ b/demo/src/protobuf/SttMessage_es6.js @@ -0,0 +1,134 @@ +/* eslint-disable block-scoped-var, id-length, no-control-regex, no-magic-numbers, no-prototype-builtins, no-redeclare, no-shadow, no-var, sort-vars */ +import * as $protobuf from "protobufjs/light" + +const $root = ($protobuf.roots.default || ($protobuf.roots.default = new $protobuf.Root())).addJSON( + { + Agora: { + nested: { + SpeechToText: { + options: { + objc_class_prefix: "Stt", + csharp_namespace: "AgoraSTTSample.Protobuf", + java_package: "io.agora.rtc.speech2text", + java_outer_classname: "AgoraSpeech2TextProtobuffer", + }, + nested: { + Text: { + fields: { + vendor: { + type: "int32", + id: 1, + }, + version: { + type: "int32", + id: 2, + }, + seqnum: { + type: "int32", + id: 3, + }, + uid: { + type: "uint32", + id: 4, + }, + flag: { + type: "int32", + id: 5, + }, + time: { + type: "int64", + id: 6, + }, + lang: { + type: "int32", + id: 7, + }, + starttime: { + type: "int32", + id: 8, + }, + offtime: { + type: "int32", + id: 9, + }, + words: { + rule: "repeated", + type: "Word", + id: 10, + }, + endOfSegment: { + type: "bool", + id: 11, + }, + durationMs: { + type: "int32", + id: 12, + }, + dataType: { + type: "string", + id: 13, + }, + trans: { + rule: "repeated", + type: "Translation", + id: 14, + }, + culture: { + type: "string", + id: 15, + }, + textTs: { + type: "int64", + id: 16, + }, + }, + }, + Word: { + fields: { + text: { + type: "string", + id: 1, + }, + startMs: { + type: "int32", + id: 2, + }, + durationMs: { + type: "int32", + id: 3, + }, + isFinal: { + type: "bool", + id: 4, + }, + confidence: { + type: "double", + id: 5, + }, + }, + }, + Translation: { + fields: { + isFinal: { + type: "bool", + id: 1, + }, + lang: { + type: "string", + id: 2, + }, + texts: { + rule: "repeated", + type: "string", + id: 3, + }, + }, + }, + }, + }, + }, + }, + }, +) + +export { $root as default } diff --git a/demo/src/store/index.ts b/demo/src/store/index.ts new file mode 100644 index 00000000..8c6c1482 --- /dev/null +++ b/demo/src/store/index.ts @@ -0,0 +1,21 @@ +"use client" + +import globalReducer from "./reducers/global" +import { configureStore } from '@reduxjs/toolkit' + +export * from "./provider" + +export const makeStore = () => { + return configureStore({ + reducer: { + global: globalReducer, + }, + devTools: process.env.NODE_ENV !== "production", + }) +} + +// Infer the type of makeStore +export type AppStore = ReturnType +// Infer the `RootState` and `AppDispatch` types from the store itself +export type RootState = ReturnType +export type AppDispatch = AppStore['dispatch'] diff --git a/demo/src/store/provider/index.tsx b/demo/src/store/provider/index.tsx new file mode 100644 index 00000000..f34703b7 --- /dev/null +++ b/demo/src/store/provider/index.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useRef } from 'react' +import { Provider } from 'react-redux' +import { makeStore, AppStore } from '..' + +export function StoreProvider({ + children +}: { + children: React.ReactNode +}) { + const storeRef = useRef() + + if (!storeRef.current) { + // Create the store instance the first time this renders + storeRef.current = makeStore() + } + + return {children} +} + diff --git a/demo/src/store/reducers/global.ts b/demo/src/store/reducers/global.ts new file mode 100644 index 00000000..b29f0af8 --- /dev/null +++ b/demo/src/store/reducers/global.ts @@ -0,0 +1,105 @@ +import { IOptions, IChatItem, Language, VoiceType } from "@/types" +import { createSlice, PayloadAction } from "@reduxjs/toolkit" +import { DEFAULT_OPTIONS, COLOR_LIST, setOptionsToLocal, genRandomChatList } from "@/common" + +export interface InitialState { + options: IOptions + roomConnected: boolean, + agentConnected: boolean, + themeColor: string, + language: Language + voiceType: VoiceType + chatItems: IChatItem[], + graphName: string +} + +const getInitialState = (): InitialState => { + return { + options: DEFAULT_OPTIONS, + themeColor: COLOR_LIST[0].active, + roomConnected: false, + agentConnected: false, + language: "en-US", + voiceType: "male", + chatItems: [], + graphName: "camera.va.openai.azure" + } +} + +export const globalSlice = createSlice({ + name: "global", + initialState: getInitialState(), + reducers: { + setOptions: (state, action: PayloadAction>) => { + state.options = { ...state.options, ...action.payload } + setOptionsToLocal(state.options) + }, + setThemeColor: (state, action: PayloadAction) => { + state.themeColor = action.payload + document.documentElement.style.setProperty('--theme-color', action.payload); + }, + setRoomConnected: (state, action: PayloadAction) => { + state.roomConnected = action.payload + }, + addChatItem: (state, action: PayloadAction) => { + const { userId, text, isFinal, type, time } = action.payload + const LastFinalIndex = state.chatItems.findLastIndex((el) => { + return el.userId == userId && el.isFinal + }) + const LastNonFinalIndex = state.chatItems.findLastIndex((el) => { + return el.userId == userId && !el.isFinal + }) + let LastFinalItem = state.chatItems[LastFinalIndex] + let LastNonFinalItem = state.chatItems[LastNonFinalIndex] + if (LastFinalItem) { + // has last final Item + if (time <= LastFinalItem.time) { + // discard + console.log("[test] addChatItem, time < last final item, discard!:", text, isFinal, type) + return + } else { + if (LastNonFinalItem) { + console.log("[test] addChatItem, update last item(none final):", text, isFinal, type) + state.chatItems[LastNonFinalIndex] = action.payload + } else { + console.log("[test] addChatItem, add new item:", text, isFinal, type) + state.chatItems.push(action.payload) + } + } + } else { + // no last final Item + if (LastNonFinalItem) { + console.log("[test] addChatItem, update last item(none final):", text, isFinal, type) + state.chatItems[LastNonFinalIndex] = action.payload + } else { + console.log("[test] addChatItem, add new item:", text, isFinal, type) + state.chatItems.push(action.payload) + } + } + state.chatItems.sort((a, b) => a.time - b.time) + }, + setAgentConnected: (state, action: PayloadAction) => { + state.agentConnected = action.payload + }, + setLanguage: (state, action: PayloadAction) => { + state.language = action.payload + }, + setGraphName: (state, action: PayloadAction) => { + state.graphName = action.payload + }, + setVoiceType: (state, action: PayloadAction) => { + state.voiceType = action.payload + }, + reset: (state) => { + Object.assign(state, getInitialState()) + document.documentElement.style.setProperty('--theme-color', COLOR_LIST[0].active); + }, + }, +}) + +export const { reset, setOptions, + setRoomConnected, setAgentConnected, setVoiceType, + addChatItem, setThemeColor, setLanguage, setGraphName } = + globalSlice.actions + +export default globalSlice.reducer diff --git a/demo/src/types/index.ts b/demo/src/types/index.ts new file mode 100644 index 00000000..f5492003 --- /dev/null +++ b/demo/src/types/index.ts @@ -0,0 +1,62 @@ +export type Language = "en-US" | "zh-CN" | "ja-JP" | "ko-KR" +export type VoiceType = "male" | "female" + +export interface ColorItem { + active: string, + default: string +} + + +export interface IOptions { + channel: string, + userName: string, + userId: number +} + + +export interface IChatItem { + userId: number | string, + userName?: string, + text: string + type: "agent" | "user" + isFinal?: boolean + time: number +} + + +export interface ITextItem { + dataType: "transcribe" | "translate" + uid: string + time: number + text: string + isFinal: boolean +} + +export interface GraphOptionItem { + label: string + value: string +} + +export interface LanguageOptionItem { + label: string + value: Language +} + + +export interface VoiceOptionItem { + label: string + value: VoiceType +} + + +export interface OptionType { + value: string; + label: string; +} + + +export interface IPdfData { + fileName: string, + collection: string +} + diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 00000000..15dcdd38 --- /dev/null +++ b/demo/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "svgr.d.ts", + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index c97e9e50..7b42c1b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: astra_agents_dev: - image: ghcr.io/ten-framework/astra_agents_build:0.4.0 + image: ghcr.io/ten-framework/ten_agent_build:0.1.0 container_name: astra_agents_dev platform: linux/amd64 tty: true @@ -19,7 +19,7 @@ services: networks: - astra_network astra_playground: - image: ghcr.io/ten-framework/astra_playground:v0.4.1-2-gb324bcd + image: ghcr.io/ten-framework/astra_playground:v0.4.1-9-g09a1df2 container_name: astra_playground restart: always ports: @@ -28,6 +28,17 @@ services: - astra_network environment: - AGENT_SERVER_URL=http://astra_agents_dev:8080 + - TEN_DEV_SERVER_URL=http://astra_agents_dev:49483 + agent_demo_ui: + image: ghcr.io/ten-framework/agent_demo:v0.4.1-9-g09a1df2 + container_name: agent_demo_ui + restart: always + ports: + - "3002:3000" + networks: + - astra_network + environment: + - AGENT_SERVER_URL=http://astra_agents_dev:8080 # use this when you want to run the playground in local development mode # astra_playground_dev: diff --git a/playground/.env b/playground/.env index 5f92b324..8739b9b0 100644 --- a/playground/.env +++ b/playground/.env @@ -1 +1,2 @@ -AGENT_SERVER_URL=http://localhost:8080 \ No newline at end of file +AGENT_SERVER_URL=http://localhost:8080 +TEN_DEV_SERVER_URL=http://localhost:49483 \ No newline at end of file diff --git a/playground/package.json b/playground/package.json index 664fb9f8..1a8d6f4f 100644 --- a/playground/package.json +++ b/playground/package.json @@ -10,32 +10,33 @@ "proto": "pbjs -t json-module -w commonjs -o src/protobuf/SttMessage.js src/protobuf/SttMessage.proto" }, "dependencies": { - "react": "^18", - "react-dom": "^18", + "@ant-design/icons": "^5.3.7", + "@reduxjs/toolkit": "^2.2.3", + "agora-rtc-sdk-ng": "^4.21.0", + "antd": "^5.15.3", + "axios": "^1.7.7", "next": "14.2.4", - "redux": "^5.0.1", "protobufjs": "^7.2.5", + "react": "^18", + "react-colorful": "^5.6.1", + "react-dom": "^18", "react-redux": "^9.1.0", - "@reduxjs/toolkit": "^2.2.3", - "antd": "^5.15.3", - "@ant-design/icons": "^5.3.7", - "agora-rtc-sdk-ng": "^4.21.0", - "react-colorful": "^5.6.1" + "redux": "^5.0.1" }, "devDependencies": { "@minko-fe/postcss-pxtoviewport": "^1.3.2", - "typescript": "^5", + "@svgr/webpack": "^8.1.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "@types/react-redux": "^7.1.22", + "autoprefixer": "^10.4.16", "eslint": "^8", "eslint-config-next": "14.2.4", - "autoprefixer": "^10.4.16", "postcss": "^8.4.31", + "protobufjs-cli": "^1.1.2", "sass": "^1.77.5", - "@svgr/webpack": "^8.1.0", - "protobufjs-cli": "^1.1.2" + "typescript": "^5" }, "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" -} \ No newline at end of file +} diff --git a/playground/src/app/api/agents/start/route.tsx b/playground/src/app/api/agents/start/route.tsx new file mode 100644 index 00000000..a346ea1f --- /dev/null +++ b/playground/src/app/api/agents/start/route.tsx @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import axios from 'axios'; + +/** + * Handles the POST request to start an agent. + * + * @param request - The NextRequest object representing the incoming request. + * @returns A NextResponse object representing the response to be sent back to the client. + */ +export async function POST(request: NextRequest) { + try { + const { AGENT_SERVER_URL } = process.env; + + // Check if environment variables are available + if (!AGENT_SERVER_URL) { + throw "Environment variables are not available"; + } + + const body = await request.json(); + const { + request_id, + channel_name, + user_uid, + graph_name, + language, + voice_type, + properties + } = body; + + // Send a POST request to start the agent + const response = await axios.post(`${AGENT_SERVER_URL}/start`, { + request_id, + channel_name, + user_uid, + graph_name, + // Get the graph properties based on the graph name, language, and voice type + properties: properties, + }); + + const responseData = response.data; + + return NextResponse.json(responseData, { status: response.status }); + } catch (error) { + if (error instanceof Response) { + const errorData = await error.json(); + return NextResponse.json(errorData, { status: error.status }); + } else { + return NextResponse.json({ code: "1", data: null, msg: "Internal Server Error" }, { status: 500 }); + } + } +} \ No newline at end of file diff --git a/playground/src/app/global.css b/playground/src/app/global.css index f7007287..7a1e1861 100644 --- a/playground/src/app/global.css +++ b/playground/src/app/global.css @@ -8,6 +8,7 @@ html, body { background-color: #0F0F11; font-family: "PingFang SC"; + height: 100%; } a { diff --git a/playground/src/app/page.tsx b/playground/src/app/page.tsx index 1bdcdeda..882aa4bd 100644 --- a/playground/src/app/page.tsx +++ b/playground/src/app/page.tsx @@ -1,14 +1,35 @@ -import LoginCard from "@/components/loginCard" -import styles from "./index.module.scss" +"use client" -export default function Login() { +import AuthInitializer from "@/components/authInitializer" +import { getRandomChannel, getRandomUserId, isMobile, useAppDispatch, useAppSelector } from "@/common" +import dynamic from 'next/dynamic' +import { useEffect, useState } from "react" +import { setOptions } from "@/store/reducers/global" + +const PCEntry = dynamic(() => import('@/platform/pc/entry'), { + ssr: false, +}) + +const MobileEntry = dynamic(() => import('@/platform/mobile/entry'), { + ssr: false, +}) + +export default function Home() { + const dispatch = useAppDispatch() + const [mobile, setMobile] = useState(null); + + + useEffect(() => { + setMobile(isMobile()) + }) return ( -
-
-
-
- -
+ mobile === null ? <> : + + {mobile ? : } + ); } + + + diff --git a/playground/src/common/hooks.ts b/playground/src/common/hooks.ts index 9759fa29..ff1d8050 100644 --- a/playground/src/common/hooks.ts +++ b/playground/src/common/hooks.ts @@ -129,3 +129,16 @@ export const usePrevious = (value: any) => { }; +export const useGraphExtensions = () => { + const graphName = useAppSelector(state => state.global.graphName); + const nodes = useAppSelector(state => state.global.extensions); + const [graphExtensions, setGraphExtensions] = useState>({}); + + useEffect(() => { + if (nodes && nodes[graphName]) { + setGraphExtensions(nodes[graphName]); + } + }, [graphName, nodes]); + + return graphExtensions; +}; \ No newline at end of file diff --git a/playground/src/common/request.ts b/playground/src/common/request.ts index a6acda1f..ca8b566f 100644 --- a/playground/src/common/request.ts +++ b/playground/src/common/request.ts @@ -1,5 +1,6 @@ import { genUUID } from "./utils" import { Language } from "@/types" +import axios from "axios" interface StartRequestConfig { channel: string @@ -7,6 +8,7 @@ interface StartRequestConfig { graphName: string, language: Language, voiceType: "male" | "female" + properties: Record } interface GenAgoraDataConfig { @@ -23,37 +25,26 @@ export const apiGenAgoraData = async (config: GenAgoraDataConfig) => { uid: userId, channel_name: channel } - let resp: any = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }) - resp = (await resp.json()) || {} + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} return resp } export const apiStartService = async (config: StartRequestConfig): Promise => { // look at app/apis/route.tsx for the server-side implementation const url = `/api/agents/start` - const { channel, userId, graphName, language, voiceType } = config + const { channel, userId, graphName, language, voiceType, properties } = config const data = { request_id: genUUID(), channel_name: channel, user_uid: userId, graph_name: graphName, language, - voice_type: voiceType + voice_type: voiceType, + properties } - let resp: any = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }) - resp = (await resp.json()) || {} + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} return resp } @@ -64,27 +55,16 @@ export const apiStopService = async (channel: string) => { request_id: genUUID(), channel_name: channel } - let resp = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }) - resp = (await resp.json()) || {} + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} return resp } export const apiGetDocumentList = async () => { // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL const url = `/api/vector/document/preset/list` - let resp: any = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - resp = (await resp.json()) || {} + let resp: any = await axios.get(url) + resp = (resp.data) || {} if (resp.code !== "0") { throw new Error(resp.msg) } @@ -101,14 +81,8 @@ export const apiUpdateDocument = async (options: { channel: string, collection: collection: collection, file_name: fileName } - let resp: any = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }) - resp = (await resp.json()) || {} + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} return resp } @@ -121,13 +95,31 @@ export const apiPing = async (channel: string) => { request_id: genUUID(), channel_name: channel } - let resp = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }) - resp = (await resp.json()) || {} + let resp: any = await axios.post(url, data) + resp = (resp.data) || {} return resp } + +export const apiGetGraphs = async () => { + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL + const url = `/api/dev/v1/graphs` + let resp: any = await axios.get(url) + resp = (resp.data) || {} + return resp +} + +export const apiGetExtensionMetadata = async () => { + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL + const url = `/api/dev/v1/addons/extensions` + let resp: any = await axios.get(url) + resp = (resp.data) || {} + return resp +} + +export const apiGetNodes = async (graphName: string) => { + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL + const url = `/api/dev/v1/graphs/${graphName}/nodes` + let resp: any = await axios.get(url) + resp = (resp.data) || {} + return resp +} \ No newline at end of file diff --git a/playground/src/components/authInitializer/index.tsx b/playground/src/components/authInitializer/index.tsx index 5ef763a1..65336e21 100644 --- a/playground/src/components/authInitializer/index.tsx +++ b/playground/src/components/authInitializer/index.tsx @@ -1,7 +1,7 @@ "use client" import { ReactNode, useEffect } from "react" -import { useAppDispatch, getOptionsFromLocal } from "@/common" +import { useAppDispatch, getOptionsFromLocal, getRandomChannel, getRandomUserId } from "@/common" import { setOptions, reset } from "@/store/reducers/global" interface AuthInitializerProps { @@ -15,9 +15,15 @@ const AuthInitializer = (props: AuthInitializerProps) => { useEffect(() => { if (typeof window !== "undefined") { const options = getOptionsFromLocal() - if (options) { + if (options && options.channel) { dispatch(reset()) dispatch(setOptions(options)) + } else { + dispatch(reset()) + dispatch(setOptions({ + channel: getRandomChannel(), + userId: getRandomUserId(), + })) } } }, [dispatch]) diff --git a/playground/src/components/pdfSelect/upload/index.tsx b/playground/src/components/pdfSelect/upload/index.tsx index 734b4a07..4eab9684 100644 --- a/playground/src/components/pdfSelect/upload/index.tsx +++ b/playground/src/components/pdfSelect/upload/index.tsx @@ -22,6 +22,7 @@ const PdfUpload = (props: PdfSelectProps) => { accept: "application/pdf", maxCount: 1, showUploadList: false, + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL action: `/api/vector/document/upload`, data: { channel_name: channel, diff --git a/playground/src/manager/rtc/rtc.ts b/playground/src/manager/rtc/rtc.ts index 0439be78..4139d89e 100644 --- a/playground/src/manager/rtc/rtc.ts +++ b/playground/src/manager/rtc/rtc.ts @@ -103,57 +103,82 @@ export class RtcManager extends AGEventEmitter { }) }) this.client.on("stream-message", (uid: UID, stream: any) => { - this._praseData(stream) + this._parseData(stream) }) } - private _praseData(data: any): ITextItem | void { - // @ts-ignore - // const textstream = protoRoot.Agora.SpeechToText.lookup("Text").decode(data) - // if (!textstream) { - // return console.warn("Prase data failed.") - // } - let decoder = new TextDecoder('utf-8') - let decodedMessage = decoder.decode(data) - - const textstream = JSON.parse(decodedMessage) - - console.log("[test] textstream raw data", JSON.stringify(textstream)) - const { stream_id, is_final, text, text_ts, data_type } = textstream - let textStr: string = "" - let isFinal = false - const textItem: ITextItem = {} as ITextItem - textItem.uid = stream_id - textItem.time = text_ts - // switch (dataType) { - // case "transcribe": - // words.forEach((word: any) => { - // textStr += word.text - // if (word.isFinal) { - // isFinal = true - // } - // }) - textItem.dataType = "transcribe" - // textItem.language = culture - textItem.text = text - textItem.isFinal = is_final - this.emit("textChanged", textItem) - // break - // case "translate": - // if (!trans?.length) { - // return - // } - // trans.forEach((transItem: any) => { - // textStr = transItem.texts.join("") - // isFinal = !!transItem.isFinal - // textItem.dataType = "translate" - // textItem.language = transItem.lang - // textItem.isFinal = isFinal - // textItem.text = textStr - // this.emit("textChanged", textItem) - // }) - // break - // } + private _parseData(data: any): ITextItem | void { + let decoder = new TextDecoder('utf-8'); + let decodedMessage = decoder.decode(data); + const textstream = JSON.parse(decodedMessage); + + console.log("[test] textstream raw data", JSON.stringify(textstream)); + + const { stream_id, is_final, text, text_ts, data_type, message_id, part_number, total_parts } = textstream; + + if (total_parts > 0) { + // If message is split, handle it accordingly + this._handleSplitMessage(message_id, part_number, total_parts, stream_id, is_final, text, text_ts); + } else { + // If there is no message_id, treat it as a complete message + this._handleCompleteMessage(stream_id, is_final, text, text_ts); + } + } + + private messageCache: { [key: string]: { parts: string[], totalParts: number } } = {}; + + /** + * Handle complete messages (not split). + */ + private _handleCompleteMessage(stream_id: number, is_final: boolean, text: string, text_ts: number): void { + const textItem: ITextItem = { + uid: `${stream_id}`, + time: text_ts, + dataType: "transcribe", + text: text, + isFinal: is_final + }; + + if (text.trim().length > 0) { + this.emit("textChanged", textItem); + } + } + + /** + * Handle split messages, track parts, and reassemble once all parts are received. + */ + private _handleSplitMessage( + message_id: string, + part_number: number, + total_parts: number, + stream_id: number, + is_final: boolean, + text: string, + text_ts: number + ): void { + // Ensure the messageCache entry exists for this message_id + if (!this.messageCache[message_id]) { + this.messageCache[message_id] = { parts: [], totalParts: total_parts }; + } + + const cache = this.messageCache[message_id]; + + // Store the received part at the correct index (part_number starts from 1, so we use part_number - 1) + cache.parts[part_number - 1] = text; + + // Check if all parts have been received + const receivedPartsCount = cache.parts.filter(part => part !== undefined).length; + + if (receivedPartsCount === total_parts) { + // All parts have been received, reassemble the message + const fullText = cache.parts.join(''); + + // Now that the message is reassembled, handle it like a complete message + this._handleCompleteMessage(stream_id, is_final, fullText, text_ts); + + // Remove the cached message since it is now fully processed + delete this.messageCache[message_id]; + } } diff --git a/playground/src/middleware.tsx b/playground/src/middleware.tsx index 66e3a549..0c40f24f 100644 --- a/playground/src/middleware.tsx +++ b/playground/src/middleware.tsx @@ -3,16 +3,20 @@ import { NextRequest, NextResponse } from 'next/server'; import { startAgent } from './apis/routes'; -const { AGENT_SERVER_URL } = process.env; +const { AGENT_SERVER_URL, TEN_DEV_SERVER_URL } = process.env; // Check if environment variables are available if (!AGENT_SERVER_URL) { - throw "Environment variables AGENT_SERVER_URL are not available"; + throw "Environment variables AGENT_SERVER_URL are not available"; +} + +if (!TEN_DEV_SERVER_URL) { + throw "Environment variables TEN_DEV_SERVER_URL are not available"; } export function middleware(req: NextRequest) { - const { pathname } = req.nextUrl; - const url = req.nextUrl.clone(); + const { pathname } = req.nextUrl; + const url = req.nextUrl.clone(); if (pathname.startsWith(`/api/agents/`)) { if (!pathname.startsWith('/api/agents/start')) { @@ -22,7 +26,7 @@ export function middleware(req: NextRequest) { // console.log(`Rewriting request to ${url.href}`); return NextResponse.rewrite(url); } else { - return startAgent(req); + return NextResponse.next(); } } else if (pathname.startsWith(`/api/vector/`)) { @@ -35,6 +39,14 @@ export function middleware(req: NextRequest) { // Proxy all other documents requests url.href = `${AGENT_SERVER_URL}${pathname.replace('/api/token/', '/token/')}`; + // console.log(`Rewriting request to ${url.href}`); + return NextResponse.rewrite(url); + } else if (pathname.startsWith('/api/dev/')) { + + // Proxy all other documents requests + const url = req.nextUrl.clone(); + url.href = `${TEN_DEV_SERVER_URL}${pathname.replace('/api/dev/', '/api/dev-server/')}`; + // console.log(`Rewriting request to ${url.href}`); return NextResponse.rewrite(url); } else { diff --git a/playground/src/platform/mobile/description/index.tsx b/playground/src/platform/mobile/description/index.tsx index 7473d550..bae88001 100644 --- a/playground/src/platform/mobile/description/index.tsx +++ b/playground/src/platform/mobile/description/index.tsx @@ -1,6 +1,6 @@ import { setAgentConnected } from "@/store/reducers/global" import { - DESCRIPTION, useAppDispatch, useAppSelector, apiPing, genUUID, + useAppDispatch, useAppSelector, apiPing, genUUID, apiStartService, apiStopService } from "@/common" import { message } from "antd" @@ -50,7 +50,8 @@ const Description = () => { userId, graphName, language, - voiceType + voiceType, + properties: {} }) const { code, msg } = res || {} if (code != 0) { diff --git a/playground/src/platform/mobile/rtc/agent/index.module.scss b/playground/src/platform/mobile/rtc/agent/index.module.scss index fa3ae2ec..5bebb2bc 100644 --- a/playground/src/platform/mobile/rtc/agent/index.module.scss +++ b/playground/src/platform/mobile/rtc/agent/index.module.scss @@ -1,7 +1,6 @@ .agent { position: relative; display: flex; - height: 292px; padding: 20px 16px; flex-direction: column; justify-content: flex-start; @@ -22,7 +21,6 @@ } .view { - margin-top: 32px; display: flex; align-items: center; justify-content: center; diff --git a/playground/src/platform/mobile/rtc/agent/index.tsx b/playground/src/platform/mobile/rtc/agent/index.tsx index a7fd7944..159f6730 100644 --- a/playground/src/platform/mobile/rtc/agent/index.tsx +++ b/playground/src/platform/mobile/rtc/agent/index.tsx @@ -15,7 +15,6 @@ const Agent = (props: AgentProps) => { const subscribedVolumes = useMultibandTrackVolume(audioTrack, 12); return
-
Agent
{ {/* you */}
-
You
{/* microphone */} {/* camera */} diff --git a/playground/src/platform/pc/chat/index.tsx b/playground/src/platform/pc/chat/index.tsx index 64dea171..3ee02155 100644 --- a/playground/src/platform/pc/chat/index.tsx +++ b/playground/src/platform/pc/chat/index.tsx @@ -7,50 +7,78 @@ import { LANGUAGE_OPTIONS, useAppSelector, GRAPH_OPTIONS, isRagGraph, + apiGetGraphs, + apiGetNodes, + useGraphExtensions, + apiGetExtensionMetadata, } from "@/common" -import { setGraphName, setLanguage } from "@/store/reducers/global" -import { Select, } from 'antd'; +import { setExtensionMetadata, setGraphName, setGraphs, setLanguage, setExtensions } from "@/store/reducers/global" +import { Button, Modal, Select, Tabs, TabsProps, } from 'antd'; import PdfSelect from "@/components/pdfSelect" import styles from "./index.module.scss" - - +import { SettingOutlined } from "@ant-design/icons" +import EditableTable from "./table" const Chat = () => { const dispatch = useAppDispatch() - const chatItems = useAppSelector(state => state.global.chatItems) - const language = useAppSelector(state => state.global.language) + const graphs = useAppSelector(state => state.global.graphs) + const extensions = useAppSelector(state => state.global.extensions) const graphName = useAppSelector(state => state.global.graphName) + const chatItems = useAppSelector(state => state.global.chatItems) const agentConnected = useAppSelector(state => state.global.agentConnected) + const [modal2Open, setModal2Open] = useState(false) + const graphExtensions = useGraphExtensions() + const extensionMetadata = useAppSelector(state => state.global.extensionMetadata) + // const chatItems = genRandomChatList(10) const chatRef = useRef(null) + useEffect(() => { + apiGetGraphs().then((res: any) => { + let graphs = res["data"].map((item: any) => item["name"]) + dispatch(setGraphs(graphs)) + }) + apiGetExtensionMetadata().then((res: any) => { + let metadata = res["data"] + let metadataMap: Record = {} + metadata.forEach((item: any) => { + metadataMap[item["name"]] = item + }) + dispatch(setExtensionMetadata(metadataMap)) + }) + }, []) + + useEffect(() => { + if (!extensions[graphName]) { + apiGetNodes(graphName).then((res: any) => { + let nodes = res["data"] + let nodesMap: Record = {} + nodes.forEach((item: any) => { + nodesMap[item["name"]] = item + }) + dispatch(setExtensions({ graphName, nodesMap })) + }) + } + }, [graphName]) useAutoScroll(chatRef) - - const onLanguageChange = (val: any) => { - dispatch(setLanguage(val)) - } - const onGraphNameChange = (val: any) => { dispatch(setGraphName(val)) } - return
- + {isRagGraph(graphName) ? : null}
@@ -59,6 +87,36 @@ const Chat = () => { return })}
+ setModal2Open(false)} + footer={ + + } + > +

You can adjust extension properties here, the values will be overridden when the agent starts using "Connect." Note that this won't modify the property.json file.

+ { + let node = graphExtensions[key] + let addon = node["addon"] + let metadata = extensionMetadata[addon] + return { + key: node["name"], label: node["name"], children: { + let nodesMap = JSON.parse(JSON.stringify(graphExtensions)) + nodesMap[key]["property"] = data + dispatch(setExtensions({ graphName, nodesMap })) + }} + > + } + })} /> +
} diff --git a/playground/src/platform/pc/chat/table/index.tsx b/playground/src/platform/pc/chat/table/index.tsx new file mode 100644 index 00000000..944dedd4 --- /dev/null +++ b/playground/src/platform/pc/chat/table/index.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Button, Empty, ConfigProvider, Table, Input, Form, Checkbox, InputRef } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; + +// Define the data type for the table rows +interface DataType { + key: string; + value: string | number | boolean | null; +} + +// Define the props for the EditableTable component +interface EditableTableProps { + initialData: Record; + onUpdate: (updatedData: Record) => void; + metadata: Record; // Metadata with property types +} + +// Helper to convert values based on type +const convertToType = (value: any, type: string) => { + switch (type) { + case 'int64': + case 'int32': + return parseInt(value, 10); + case 'float64': + return parseFloat(value); + case 'bool': + return value === true || value === 'true'; + case 'string': + return String(value); + default: + return value; + } +}; + +const EditableTable: React.FC = ({ initialData, onUpdate, metadata }) => { + const [dataSource, setDataSource] = useState( + Object.entries(initialData).map(([key, value]) => ({ key, value })) + ); + const [editingKey, setEditingKey] = useState(''); + const [form] = Form.useForm(); + const inputRef = useRef(null); // Ref to manage focus + + // Function to check if the current row is being edited + const isEditing = (record: DataType) => record.key === editingKey; + + // Function to toggle editing on a row + const edit = (record: DataType) => { + form.setFieldsValue({ value: record.value ?? '' }); + setEditingKey(record.key); + }; + + // Function to handle when the value of a non-boolean field is changed + const handleValueChange = async (key: string) => { + try { + const row = await form.validateFields(); + const newData = [...dataSource]; + const index = newData.findIndex((item) => key === item.key); + + if (index > -1) { + const item = newData[index]; + const valueType = metadata[key]?.type || 'string'; + newData.splice(index, 1, { ...item, ...row, value: convertToType(row.value, valueType) }); + setDataSource(newData); + setEditingKey(''); + + // Notify the parent component of the update + const updatedData = Object.fromEntries(newData.map(({ key, value }) => [key, value])); + onUpdate(updatedData); + } + } catch (errInfo) { + console.log('Validation Failed:', errInfo); + } + }; + + // Toggle the checkbox for boolean values directly in the table cell + const handleCheckboxChange = (key: string, checked: boolean) => { + const newData = [...dataSource]; + const index = newData.findIndex((item) => key === item.key); + if (index > -1) { + newData[index].value = checked; // Update the boolean value + setDataSource(newData); + + // Notify the parent component of the update + const updatedData = Object.fromEntries(newData.map(({ key, value }) => [key, value])); + onUpdate(updatedData); + } + }; + + // Auto-focus on the input when entering edit mode + useEffect(() => { + if (editingKey) { + inputRef.current?.focus(); // Focus the input field when editing starts + } + }, [editingKey]); + + // Define columns for the table + const columns: ColumnsType = [ + { + title: 'Key', + dataIndex: 'key', + width: '30%', + key: 'key', + }, + { + title: 'Value', + dataIndex: 'value', + width: '50%', + key: 'value', + render: (_, record: DataType) => { + const valueType = metadata[record.key]?.type || 'string'; + + // Always display the checkbox for boolean values + if (valueType === 'bool') { + return ( + handleCheckboxChange(record.key, e.target.checked)} + /> + ); + } + + // Inline editing for other types (string, number) + const editable = isEditing(record); + return editable ? ( + + handleValueChange(record.key)} // Save on pressing Enter + onBlur={() => handleValueChange(record.key)} // Save on losing focus + /> + + ) : ( +
edit(record)}> + {record.value !== null && record.value !== undefined + ? record.value + : 'Click to edit'} +
// Display placeholder for empty values + ); + }, + }, + ]; + + return ( + ( + + + )} + > +
+ + + + ); +}; + +export default EditableTable; diff --git a/playground/src/platform/pc/description/index.tsx b/playground/src/platform/pc/description/index.tsx index 01f61063..53d7d996 100644 --- a/playground/src/platform/pc/description/index.tsx +++ b/playground/src/platform/pc/description/index.tsx @@ -1,7 +1,8 @@ import { setAgentConnected } from "@/store/reducers/global" import { - DESCRIPTION, useAppDispatch, useAppSelector, apiPing, genUUID, - apiStartService, apiStopService + useAppDispatch, useAppSelector, apiPing, genUUID, + apiStartService, apiStopService, + useGraphExtensions } from "@/common" import { Select, Button, message, Upload } from "antd" import { useEffect, useState, MouseEventHandler } from "react" @@ -17,8 +18,9 @@ const Description = () => { const userId = useAppSelector(state => state.global.options.userId) const language = useAppSelector(state => state.global.language) const voiceType = useAppSelector(state => state.global.voiceType) - const graphName = useAppSelector(state => state.global.graphName) const [loading, setLoading] = useState(false) + const graphName = useAppSelector(state => state.global.graphName) + const graphNodes = useGraphExtensions() useEffect(() => { if (channel) { @@ -45,12 +47,18 @@ const Description = () => { message.success("Agent disconnected") stopPing() } else { + let properties: Record = {} + Object.keys(graphNodes).forEach(extensionName => { + properties[extensionName] = {} + properties[extensionName] = graphNodes[extensionName].property + }) const res = await apiStartService({ channel, userId, graphName, language, - voiceType + voiceType, + properties: properties }) const { code, msg } = res || {} if (code != 0) { diff --git a/playground/src/platform/pc/entry/index.module.scss b/playground/src/platform/pc/entry/index.module.scss index f138183f..bb001f27 100644 --- a/playground/src/platform/pc/entry/index.module.scss +++ b/playground/src/platform/pc/entry/index.module.scss @@ -2,16 +2,27 @@ position: relative; height: 100%; box-sizing: border-box; + display: flex; + flex-direction: column; .content { position: relative; padding: 16px; box-sizing: border-box; + display: flex; + height: calc(100% - 64px); + flex-direction: column; .body { margin-top: 16px; display: flex; gap: 24px; + flex-grow: 1; + + .chat { + display: flex; + flex-grow: 1; + } } } -} +} \ No newline at end of file diff --git a/playground/src/platform/pc/entry/index.tsx b/playground/src/platform/pc/entry/index.tsx index e7acd7f1..a7ee7592 100644 --- a/playground/src/platform/pc/entry/index.tsx +++ b/playground/src/platform/pc/entry/index.tsx @@ -11,8 +11,12 @@ const PCEntry = () => {
- - +
+ +
+
+ +
diff --git a/playground/src/platform/pc/rtc/agent/index.module.scss b/playground/src/platform/pc/rtc/agent/index.module.scss index fa3ae2ec..5bebb2bc 100644 --- a/playground/src/platform/pc/rtc/agent/index.module.scss +++ b/playground/src/platform/pc/rtc/agent/index.module.scss @@ -1,7 +1,6 @@ .agent { position: relative; display: flex; - height: 292px; padding: 20px 16px; flex-direction: column; justify-content: flex-start; @@ -22,7 +21,6 @@ } .view { - margin-top: 32px; display: flex; align-items: center; justify-content: center; diff --git a/playground/src/platform/pc/rtc/agent/index.tsx b/playground/src/platform/pc/rtc/agent/index.tsx index a7fd7944..159f6730 100644 --- a/playground/src/platform/pc/rtc/agent/index.tsx +++ b/playground/src/platform/pc/rtc/agent/index.tsx @@ -15,7 +15,6 @@ const Agent = (props: AgentProps) => { const subscribedVolumes = useMultibandTrackVolume(audioTrack, 12); return
-
Agent
{ const dispatch = useAppDispatch() const options = useAppSelector(state => state.global.options) - const voiceType = useAppSelector(state => state.global.voiceType) - const agentConnected = useAppSelector(state => state.global.agentConnected) const { userId, channel } = options const [videoTrack, setVideoTrack] = useState() const [audioTrack, setAudioTrack] = useState() @@ -97,25 +93,15 @@ const Rtc = () => { } } - const onVoiceChange = (value: any) => { - dispatch(setVoiceType(value)) - } - return
Audio & Video - } - options={VOICE_OPTIONS} onChange={onVoiceChange}>
{/* agent */} {/* you */}
-
You
{/* microphone */} {/* camera */} diff --git a/playground/src/store/reducers/global.ts b/playground/src/store/reducers/global.ts index b29f0af8..dbdbff3b 100644 --- a/playground/src/store/reducers/global.ts +++ b/playground/src/store/reducers/global.ts @@ -10,7 +10,10 @@ export interface InitialState { language: Language voiceType: VoiceType chatItems: IChatItem[], - graphName: string + graphName: string, + graphs: string[], + extensions: Record, + extensionMetadata: Record } const getInitialState = (): InitialState => { @@ -22,7 +25,10 @@ const getInitialState = (): InitialState => { language: "en-US", voiceType: "male", chatItems: [], - graphName: "camera.va.openai.azure" + graphName: "camera.va.openai.azure", + graphs: [], + extensions: {}, + extensionMetadata: {}, } } @@ -87,6 +93,16 @@ export const globalSlice = createSlice({ setGraphName: (state, action: PayloadAction) => { state.graphName = action.payload }, + setGraphs: (state, action: PayloadAction) => { + state.graphs = action.payload + }, + setExtensions: (state, action: PayloadAction>) => { + let { graphName, nodesMap } = action.payload + state.extensions[graphName] = nodesMap + }, + setExtensionMetadata: (state, action: PayloadAction>) => { + state.extensionMetadata = action.payload + }, setVoiceType: (state, action: PayloadAction) => { state.voiceType = action.payload }, @@ -99,7 +115,7 @@ export const globalSlice = createSlice({ export const { reset, setOptions, setRoomConnected, setAgentConnected, setVoiceType, - addChatItem, setThemeColor, setLanguage, setGraphName } = + addChatItem, setThemeColor, setLanguage, setGraphName, setGraphs, setExtensions, setExtensionMetadata } = globalSlice.actions export default globalSlice.reducer diff --git a/playground/src/types/index.ts b/playground/src/types/index.ts index 02c6bd70..f5492003 100644 --- a/playground/src/types/index.ts +++ b/playground/src/types/index.ts @@ -27,7 +27,6 @@ export interface IChatItem { export interface ITextItem { dataType: "transcribe" | "translate" uid: string - language: string time: number text: string isFinal: boolean diff --git a/server/internal/http_server.go b/server/internal/http_server.go index f3fea8b2..d7c41915 100644 --- a/server/internal/http_server.go +++ b/server/internal/http_server.go @@ -15,7 +15,6 @@ import ( "net/http" "os" "path/filepath" - "regexp" "strings" "time" @@ -381,23 +380,6 @@ func (s *HttpServer) output(c *gin.Context, code *Code, data any, httpStatus ... c.JSON(httpStatus[0], gin.H{"code": code.code, "msg": code.msg, "data": data}) } -func replaceEnvVarsInJSON(jsonData string) string { - // Regex to find all occurrences of $VAR_NAME - re := regexp.MustCompile(`"\$(\w+)"`) - - // Function to replace the match with the environment variable value - result := re.ReplaceAllStringFunc(jsonData, func(match string) string { - // Extract the variable name (removing the leading $ and surrounding quotes) - envVar := strings.Trim(match, "\"$") - // Get the environment variable value - value := os.Getenv(envVar) - // Replace with the value (keeping it quoted) - return fmt.Sprintf("\"%s\"", value) - }) - - return result -} - func (s *HttpServer) processProperty(req *StartReq) (propertyJsonFile string, logFile string, err error) { content, err := os.ReadFile(PropertyJsonFile) if err != nil { @@ -430,10 +412,10 @@ func (s *HttpServer) processProperty(req *StartReq) (propertyJsonFile string, lo graphs := gjson.Get(propertyJson, "_ten.predefined_graphs").Array() // Create a new array for graphs that match the name - var newGraphs []string + var newGraphs []gjson.Result for _, graph := range graphs { if graph.Get("name").String() == graphName { - newGraphs = append(newGraphs, graph.Raw) + newGraphs = append(newGraphs, graph) } } @@ -443,20 +425,28 @@ func (s *HttpServer) processProperty(req *StartReq) (propertyJsonFile string, lo return } + // Set the array of graphs directly using sjson.Set + graphData := make([]interface{}, len(newGraphs)) + for i, graph := range newGraphs { + graphData[i] = graph.Value() // Convert gjson.Result to interface{} + } + // Replace the predefined_graphs array with the filtered array - propertyJson, _ = sjson.SetRaw(propertyJson, "_ten.predefined_graphs", fmt.Sprintf("[%s]", strings.Join(newGraphs, ","))) + propertyJson, _ = sjson.Set(propertyJson, "_ten.predefined_graphs", graphData) // Automatically start on launch propertyJson, _ = sjson.Set(propertyJson, fmt.Sprintf(`%s.auto_start`, graph), true) - // Set environment variable values to property.json - propertyJson = replaceEnvVarsInJSON(propertyJson) - // Set additional properties to property.json for extensionName, props := range req.Properties { if extKey := extensionName; extKey != "" { for prop, val := range props { - propertyJson, _ = sjson.Set(propertyJson, fmt.Sprintf(`%s.nodes.#(name=="%s").property.%s`, graph, extKey, prop), val) + // Construct the path + path := fmt.Sprintf(`%s.nodes.#(name=="%s").property.%s`, graph, extKey, prop) + propertyJson, err = sjson.Set(propertyJson, path, val) + if err != nil { + slog.Error("handlerStart set property failed", "err", err, "graph", graphName, "extensionName", extensionName, "prop", prop, "val", val, "requestId", req.RequestId, logTag) + } } } }