diff --git a/.github/workflows/build-docker.yaml b/.github/workflows/build-docker.yaml index df45ecc6..8f401d9f 100644 --- a/.github/workflows/build-docker.yaml +++ b/.github/workflows/build-docker.yaml @@ -1,53 +1,63 @@ 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" ] + branches: ["main"] workflow_dispatch: 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/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..4342a11d --- /dev/null +++ b/demo/package.json @@ -0,0 +1,41 @@ +{ + "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": { + "react": "^18", + "react-dom": "^18", + "next": "14.2.4", + "redux": "^5.0.1", + "protobufjs": "^7.2.5", + "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" + }, + "devDependencies": { + "@minko-fe/postcss-pxtoviewport": "^1.3.2", + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/react-redux": "^7.1.22", + "eslint": "^8", + "eslint-config-next": "14.2.4", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "sass": "^1.77.5", + "@svgr/webpack": "^8.1.0", + "protobufjs-cli": "^1.1.2" + }, + "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" +} \ No newline at end of file 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/playground/src/app/api/agents/start/graph.tsx b/demo/src/app/api/agents/start/graph.tsx similarity index 100% rename from playground/src/app/api/agents/start/graph.tsx rename to demo/src/app/api/agents/start/graph.tsx 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..5a7b4440 --- /dev/null +++ b/demo/src/app/api/agents/start/route.tsx @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getGraphProperties } from './graph'; + +/** + * 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; + + // 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 }); + } + } +} \ 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 00000000..21b38b96 Binary files /dev/null and b/demo/src/app/favicon.ico differ diff --git a/demo/src/app/global.css b/demo/src/app/global.css new file mode 100644 index 00000000..f7007287 --- /dev/null +++ b/demo/src/app/global.css @@ -0,0 +1,66 @@ +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + background-color: #0F0F11; + font-family: "PingFang SC"; +} + +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 00000000..02762343 Binary files /dev/null and b/demo/src/assets/background.jpg differ diff --git a/demo/src/assets/cam_mute.svg b/demo/src/assets/cam_mute.svg new file mode 100644 index 00000000..a4640ae4 --- /dev/null +++ b/demo/src/assets/cam_mute.svg @@ -0,0 +1,3 @@ + + + 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..160cc065 --- /dev/null +++ b/demo/src/common/request.ts @@ -0,0 +1,133 @@ +import { genUUID } from "./utils" +import { Language } from "@/types" + +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 fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }) + resp = (await resp.json()) || {} + 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 fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }) + resp = (await resp.json()) || {} + 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 = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }) + resp = (await resp.json()) || {} + 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()) || {} + 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 fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }) + resp = (await resp.json()) || {} + 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 = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }) + resp = (await resp.json()) || {} + 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..f138183f --- /dev/null +++ b/demo/src/platform/pc/entry/index.module.scss @@ -0,0 +1,17 @@ +.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/pc/entry/index.tsx b/demo/src/platform/pc/entry/index.tsx new file mode 100644 index 00000000..e7acd7f1 --- /dev/null +++ b/demo/src/platform/pc/entry/index.tsx @@ -0,0 +1,22 @@ +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 6ad1a0ef..1793137c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: - astra_network environment: - AGENT_SERVER_URL=http://astra_agents_dev:8080 + - TEN_DEV_SERVER_URL=http://astra_agents_dev:49483 # 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/src/app/api/agents/start/route.tsx b/playground/src/app/api/agents/start/route.tsx index 5a7b4440..78a8bc43 100644 --- a/playground/src/app/api/agents/start/route.tsx +++ b/playground/src/app/api/agents/start/route.tsx @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getGraphProperties } from './graph'; /** * Handles the POST request to start an agent. @@ -24,6 +23,7 @@ export async function POST(request: NextRequest) { graph_name, language, voice_type, + properties } = body; // Send a POST request to start the agent @@ -38,7 +38,7 @@ export async function POST(request: NextRequest) { user_uid, graph_name, // Get the graph properties based on the graph name, language, and voice type - properties: getGraphProperties(graph_name, language, voice_type), + properties: properties, }), }); 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/constant.ts b/playground/src/common/constant.ts index fee0c18e..0fd93c81 100644 --- a/playground/src/common/constant.ts +++ b/playground/src/common/constant.ts @@ -6,7 +6,6 @@ export const DEFAULT_OPTIONS: IOptions = { 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", 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 160cc065..624d5989 100644 --- a/playground/src/common/request.ts +++ b/playground/src/common/request.ts @@ -7,6 +7,7 @@ interface StartRequestConfig { graphName: string, language: Language, voiceType: "male" | "female" + properties: Record } interface GenAgoraDataConfig { @@ -15,7 +16,7 @@ interface GenAgoraDataConfig { } export const apiGenAgoraData = async (config: GenAgoraDataConfig) => { - // the request will be rewrite at next.config.mjs to send to $AGENT_SERVER_URL + // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL const url = `/api/token/generate` const { userId, channel } = config const data = { @@ -37,14 +38,15 @@ export const apiGenAgoraData = async (config: GenAgoraDataConfig) => { 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 { 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", @@ -131,3 +133,42 @@ export const apiPing = async (channel: string) => { resp = (await resp.json()) || {} 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 fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + } + }) + resp = (await resp.json()) || {} + 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 fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + } + }) + resp = (await resp.json()) || {} + 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 fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + } + }) + resp = (await resp.json()) || {} + 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/middleware.tsx b/playground/src/middleware.tsx index 724e0b4d..d25c18bf 100644 --- a/playground/src/middleware.tsx +++ b/playground/src/middleware.tsx @@ -2,15 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'; -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 { pathname } = req.nextUrl; if (pathname.startsWith('/api/agents/')) { if (!pathname.startsWith('/api/agents/start')) { @@ -21,6 +25,8 @@ export function middleware(req: NextRequest) { // console.log(`Rewriting request to ${url.href}`); return NextResponse.rewrite(url); + } else { + return NextResponse.next(); } } else if (pathname.startsWith('/api/vector/')) { @@ -35,6 +41,14 @@ export function middleware(req: NextRequest) { 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 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..0904d4dc --- /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 } 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 a9a055cd..09d2da5a 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