├── .nvmrc
├── .eslintrc.json
├── src
├── state
│ ├── index.ts
│ ├── common.ts
│ └── settings.ts
├── constants
│ ├── index.ts
│ ├── common.ts
│ └── prompts.ts
├── lib
│ └── utils.ts
├── components
│ ├── NotificationBar.tsx
│ ├── UserPanel.tsx
│ ├── AuthModal.tsx
│ ├── VoiceSetting.tsx
│ ├── Footer.tsx
│ ├── Navbar.tsx
│ ├── ui
│ │ ├── switch.tsx
│ │ ├── button.tsx
│ │ └── dialog.tsx
│ ├── User
│ │ ├── UserInfo.tsx
│ │ ├── LoginForm.tsx
│ │ └── RegisterForm.tsx
│ ├── Settings.tsx
│ ├── PlayerBtn.tsx
│ ├── RecordBtn.tsx
│ ├── Onboarding.tsx
│ ├── AIPanel.tsx
│ ├── MessageItem.tsx
│ ├── HistoryPanel.tsx
│ ├── Content.tsx
│ └── TalkBoost.tsx
├── utils.ts
├── apis
│ ├── chatAI.ts
│ └── azureTTS.ts
├── i18n.ts
├── locales
│ ├── zh.json
│ └── en.json
└── globals.css
├── pages
├── index.tsx
├── _document.tsx
└── _app.tsx
├── public
├── favicon.ico
└── images
│ └── logo.png
├── prisma
├── migrations
│ ├── migration_lock.toml
│ └── 20230820080439_init
│ │ └── migration.sql
└── schema.prisma
├── .prettierrc.json
├── .env.example
├── app
└── api
│ └── user
│ ├── logout
│ └── route.ts
│ ├── login
│ └── route.ts
│ └── register
│ └── route.ts
├── next.config.js
├── components.json
├── postcss.config.js
├── .vscode
└── settings.json
├── .gitignore
├── README_CN.md
├── .github
└── workflows
│ └── comment-help-wanted.yml
├── scripts
└── addDatabaseUser.mjs
├── tsconfig.json
├── README.md
├── package.json
└── tailwind.config.js
/.nvmrc:
--------------------------------------------------------------------------------
1 | 18
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next", "prettier"]
3 | }
4 |
--------------------------------------------------------------------------------
/src/state/index.ts:
--------------------------------------------------------------------------------
1 | export * from './common'
2 | export * from './settings'
3 |
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './prompts'
2 | export * from './common'
3 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import TalkBoost from '@/components/TalkBoost'
2 |
3 | export default TalkBoost
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circle-hotaru/talk-boost-ai/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/circle-hotaru/talk-boost-ai/HEAD/public/images/logo.png
--------------------------------------------------------------------------------
/src/state/common.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'jotai'
2 |
3 | export const authModalOpenAtom = atom(false)
4 | export const userInfoModalOpenAtom = atom(false)
5 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "semi": false,
4 | "tabWidth": 2,
5 | "singleQuote": true,
6 | "jsxSingleQuote": true,
7 | "plugins": ["prettier-plugin-tailwindcss"]
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_CHAT_API_KEY=
2 | NEXT_PUBLIC_CHAT_API_PROXY=
3 | NEXT_PUBLIC_AZURE_SPEECH_KEY=
4 | NEXT_PUBLIC_AZURE_SPEECH_REGION=
5 | GOOGLE_ANALYTICS_ID=
6 | DATABASE_URL=
7 | DATABASE_DIRECT_URL=
8 | JWT_SECRET=
--------------------------------------------------------------------------------
/app/api/user/logout/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 |
3 | export async function GET() {
4 | const response = NextResponse.json({})
5 | response.cookies.set('token', '', { maxAge: 0 })
6 | return response
7 | }
8 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: false,
4 | swcMinify: true,
5 | experimental: {
6 | appDir: true,
7 | },
8 | }
9 |
10 | module.exports = nextConfig
11 |
--------------------------------------------------------------------------------
/src/constants/common.ts:
--------------------------------------------------------------------------------
1 | export const voices = [
2 | { id: 'en-US-AvaMultilingualNeural', name: 'Ava' },
3 | { id: 'en-US-AndrewMultilingualNeural', name: 'Andrew' },
4 | { id: 'en-US-EmmaMultilingualNeural', name: 'Emma' },
5 | { id: 'en-US-BrianMultilingualNeural', name: 'Brian' },
6 | ]
7 |
--------------------------------------------------------------------------------
/src/state/settings.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'jotai'
2 | import { voices } from '@/constants'
3 |
4 | export const openVoiceAtom = atom(true)
5 | export const openAiCount = atom(0)
6 |
7 | export const recordNowHistory = atom(0)
8 |
9 | export const recordNowHistoryName = atom('')
10 | export const voiceIdAtom = atom(voices[0].id)
11 |
--------------------------------------------------------------------------------
/src/components/NotificationBar.tsx:
--------------------------------------------------------------------------------
1 | import { Alert } from 'antd'
2 | import { useTranslation } from 'react-i18next'
3 |
4 | const NotificationBar = () => {
5 | const { t } = useTranslation()
6 | return (
7 |
10 | )
11 | }
12 |
13 | export default NotificationBar
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | 'postcss-flexbugs-fixes',
4 | 'postcss-preset-env',
5 | [
6 | 'tailwindcss',
7 | {
8 | // 在生产环境中移除未使用的 CSS 代码
9 | purge: process.env.NODE_ENV === 'production',
10 | // 配置文件路径
11 | config: './tailwind.config.js',
12 | },
13 | ],
14 | [
15 | 'autoprefixer',
16 | {
17 | // 针对 flexbox 布局的一些 bug 进行修复
18 | flexbox: 'no-2009',
19 | },
20 | ],
21 | ],
22 | }
23 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.tabSize": 2,
4 | "editor.formatOnSave": true,
5 | "prettier.singleQuote": true,
6 | "prettier.semi": false,
7 | "prettier.trailingComma": "es5",
8 | "typescript.tsdk": "node_modules/.pnpm/typescript@5.0.3/node_modules/typescript/lib",
9 | "typescript.enablePromptUseWorkspaceTsdk": true,
10 | "cSpell.words": [
11 | "incircle",
12 | "antd",
13 | "IELTS",
14 | "OPENAI",
15 | "Avvvatars",
16 | "lucide-react"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/prisma/migrations/20230820080439_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "users" (
3 | "userId" SERIAL NOT NULL,
4 | "name" TEXT NOT NULL,
5 | "email" TEXT NOT NULL,
6 | "password" TEXT NOT NULL,
7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8 | "updatedAt" TIMESTAMP(3) NOT NULL,
9 |
10 | CONSTRAINT "users_pkey" PRIMARY KEY ("userId")
11 | );
12 |
13 | -- CreateIndex
14 | CREATE UNIQUE INDEX "users_name_key" ON "users"("name");
15 |
16 | -- CreateIndex
17 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
18 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "postgresql"
10 | url = env("DATABASE_URL")
11 | directUrl = env("DATABASE_DIRECT_URL")
12 | }
13 |
14 |
15 | model User {
16 | userId Int @id @default(autoincrement())
17 | name String @unique
18 | email String @unique
19 | password String
20 |
21 | createdAt DateTime @default(now())
22 | updatedAt DateTime @updatedAt
23 |
24 | @@map("users")
25 | }
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export const isIOS = () => {
2 | const userAgent = navigator.userAgent.toLowerCase()
3 | return /iphone|ipad|ipod/.test(userAgent)
4 | }
5 |
6 | export const setLocal = (name, data) => {
7 | if (!name) return
8 | if (typeof data !== 'string') {
9 | data = JSON.stringify(data)
10 | }
11 | window.localStorage.setItem(name, data)
12 | }
13 |
14 | export const getLocal = (name) => {
15 | if (!name) return
16 | let item = window.localStorage.getItem(name)
17 | return JSON.parse(item)
18 | }
19 |
20 | export const removeLocal = (name) => {
21 | if (!name) return
22 | window.localStorage.removeItem(name)
23 | }
24 |
--------------------------------------------------------------------------------
/README_CN.md:
--------------------------------------------------------------------------------
1 | TalkBoostAI
2 |
3 | [English](./README.md) | 简体中文
4 |
5 | ## 🌟 Introduction
6 |
7 | TalkBoostAI 是一个利用 AI 来帮助你提高英语口语交流能力的网页应用,免费体验 chatGPT 的 AI 模型。
8 |
9 | ## ✨ 演示
10 |
11 | 欢迎直接体验[我们的网站](https://talk.incircles.xyz/) :)
12 |
13 | ## 🚀 产品功能
14 |
15 | - 💬 与 AI 自然对话,交流无压力
16 | - 🎙️ 支持语音交流,沉浸式练习
17 | - ⚡️ 完全免费体验 chatGPT 的 AI 模型
18 |
19 | ## 👨🚀 开发
20 |
21 | ### 设置 .env
22 |
23 | 请在项目目录中,创建一个名为 .env 的文件。
24 |
25 | ### 设置数据库
26 |
27 | https://supabase.com/partners/integrations/prisma
28 |
29 | ### 运行应用
30 |
31 | ```
32 | pnpm install
33 | pnpm dev
34 | ```
35 |
36 | 在浏览器打开 http://localhost:3000 查看应用。
37 |
--------------------------------------------------------------------------------
/.github/workflows/comment-help-wanted.yml:
--------------------------------------------------------------------------------
1 | name: Comment help wanted
2 | on:
3 | issues:
4 | types:
5 | - labeled
6 | jobs:
7 | comment-help-wanted:
8 | if: github.event.label.name == 'help wanted'
9 | runs-on: ubuntu-latest
10 | permissions:
11 | issues: write
12 | steps:
13 | - name: Add comment
14 | run: gh issue comment "$NUMBER" --body "$BODY"
15 | env:
16 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
17 | GH_REPO: ${{ github.repository }}
18 | NUMBER: ${{ github.event.issue.number }}
19 | BODY: >
20 | This issue is available for anyone to work on.
21 | **Make sure to reference this issue in your pull request.**
22 | :sparkles: Thank you for your contribution! :sparkles:
23 |
--------------------------------------------------------------------------------
/scripts/addDatabaseUser.mjs:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client'
2 | import bcrypt from 'bcrypt'
3 |
4 | const prisma = new PrismaClient()
5 |
6 | async function main() {
7 | // ... you will write your Prisma Client queries here
8 | const salt = bcrypt.genSaltSync(10)
9 | const hashedPassword = await bcrypt.hash('password123', salt)
10 |
11 | const user = await prisma.user.create({
12 | data: {
13 | name: 'Alice',
14 | email: 'Alice@example.com',
15 | password: hashedPassword,
16 | },
17 | })
18 | console.log('User created successfully!\n', user)
19 | }
20 |
21 | main()
22 | .then(async () => {
23 | await prisma.$disconnect()
24 | })
25 | .catch(async (e) => {
26 | console.error(e)
27 | await prisma.$disconnect()
28 | process.exit(1)
29 | })
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": false,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "@/*": ["src/*"]
20 | },
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ],
26 | "strictNullChecks": false
27 | },
28 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
29 | "exclude": ["node_modules"]
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/UserPanel.tsx:
--------------------------------------------------------------------------------
1 | import ReactMarkdown from 'react-markdown'
2 | import Avvvatars from 'avvvatars-react'
3 | import { isMobile } from 'react-device-detect'
4 |
5 | const UserPanel: React.FC<{ content: string }> = ({ content }) => {
6 | const user = JSON.parse(window.localStorage.getItem('user') ?? '{}')
7 |
8 | return (
9 |
10 |
15 | {content}
16 |
17 |
24 |
25 | )
26 | }
27 |
28 | export default UserPanel
29 |
--------------------------------------------------------------------------------
/src/apis/chatAI.ts:
--------------------------------------------------------------------------------
1 | import { Messages } from '@/components/TalkBoost'
2 |
3 | const defaultAPI = 'https://api.openai.com'
4 | const proxyAPI = process.env.NEXT_PUBLIC_CHAT_API_PROXY
5 | const apiUrl = proxyAPI ?? defaultAPI
6 | const apiKey = process.env.NEXT_PUBLIC_CHAT_API_KEY
7 |
8 | export const requestChatAI = async (messages: Messages): Promise => {
9 | const requestOptions = {
10 | method: 'POST',
11 | headers: {
12 | 'Content-Type': 'application/json',
13 | Authorization: !!apiKey ? `Bearer ${apiKey}` : null,
14 | },
15 | body: JSON.stringify({
16 | model: 'gpt-4.1-mini',
17 | messages: messages,
18 | }),
19 | }
20 |
21 | const response = await fetch(`${apiUrl}/v1/chat/completions`, requestOptions)
22 | if (!response.ok) {
23 | throw new Error('Failed to get AI response')
24 | }
25 | const data = await response.json()
26 | return data.choices[0].message.content
27 | }
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | TalkBoostAI
2 |
3 | English | [简体中文](./README_CN.md)
4 |
5 | ## 🌟 Introduction
6 |
7 | TalkBoostAI is a web application that utilizes AI to help you improve your English speaking and conversation skills. It allows you to freely experience chatGPT's AI model.
8 |
9 | ## ✨ Demo
10 |
11 | For the best demo experience, try [our site](https://talk.incircles.xyz/) directly :)
12 |
13 | ## 🚀 Features
14 |
15 | - 💬 Converse with AI naturally, communicate without stress
16 | - 🎙️ Voice input and output for immersive practice
17 | - ⚡️ Freely experience chatGPT's AI model
18 |
19 | ## 👨🚀 Development
20 |
21 | ### Setup .env
22 |
23 | In the project directory, create a file called .env.
24 |
25 | ### Setup database
26 |
27 | https://supabase.com/partners/integrations/prisma
28 |
29 | ### Running the Application
30 |
31 | ```
32 | pnpm install
33 | pnpm dev
34 | ```
35 |
36 | Open http://localhost:3000 to see the app.
37 |
--------------------------------------------------------------------------------
/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next'
2 | import { initReactI18next } from 'react-i18next'
3 | import LanguageDetector from 'i18next-browser-languagedetector'
4 | import enTranslation from './locales/en.json'
5 | import zhTranslation from './locales/zh.json'
6 |
7 | // the translations
8 | // (tip move them in a JSON file and import them,
9 | // or even better, manage them separated from your code: https://react.i18next.com/guides/multiple-translation-files)
10 | const resources = {
11 | en: {
12 | translation: enTranslation,
13 | },
14 | zh: {
15 | translation: zhTranslation,
16 | },
17 | }
18 |
19 | i18n
20 | .use(LanguageDetector)
21 | .use(initReactI18next) // passes i18n down to react-i18next
22 | .init({
23 | resources,
24 | fallbackLng: 'en',
25 | detection: {
26 | order: ['navigator', 'htmlTag'],
27 | },
28 | interpolation: {
29 | escapeValue: false,
30 | },
31 | })
32 |
33 | export default i18n
34 |
--------------------------------------------------------------------------------
/src/locales/zh.json:
--------------------------------------------------------------------------------
1 | {
2 | "mode": {
3 | "english_communication": "😊 英语交流",
4 | "french_communication": "🥐 法语交流",
5 | "japanese_communication": "🌸 日语交流",
6 | "spanish_communication": "🌮 西班牙语交流",
7 | "IELTS_speaking_test": "🎓 雅思口语测试"
8 | },
9 | "error": {
10 | "input_username": "请输入用户名!",
11 | "input_email": "请输入邮箱!",
12 | "input_password": "请输入密码!"
13 | },
14 | "chat": "聊天",
15 | "in_chat": "进行中",
16 | "send": "发送",
17 | "translate": "翻译",
18 | "use_voice_answer": "使用语音回答",
19 | "new_chat": "新的对话",
20 | "register": "注册",
21 | "login": "登录",
22 | "username": "用户名",
23 | "email": "邮箱",
24 | "password": "密码",
25 | "go_to_login": "去登录",
26 | "go_to_register": "去注册",
27 | "logout": "登出",
28 | "user_exists": "用户已存在",
29 | "history": "历史记录",
30 | "delete": "删除",
31 | "switch_language": "切换语言",
32 | "voice_model": "语音模型",
33 | "notification": "语音服务暂不可用",
34 | "tarot_master_link": "塔罗大师"
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/AuthModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { Modal } from 'antd'
4 | import { useAtom } from 'jotai'
5 | import { authModalOpenAtom } from '@/state'
6 | import RegisterForm from './User/RegisterForm'
7 | import LoginForm from './User/LoginForm'
8 |
9 | const AuthModal: React.FC = () => {
10 | const { t } = useTranslation()
11 | const [open, setOpen] = useAtom(authModalOpenAtom)
12 | const [isRegister, setIsRegister] = useState(true)
13 |
14 | return (
15 | setOpen(false)}
19 | footer={null}
20 | closeIcon={false}
21 | >
22 | {isRegister ? (
23 | setIsRegister(false)} />
24 | ) : (
25 | setIsRegister(true)} />
26 | )}
27 |
28 | )
29 | }
30 |
31 | export default AuthModal
32 |
--------------------------------------------------------------------------------
/src/components/VoiceSetting.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { useAtom } from 'jotai'
3 | import { voiceIdAtom } from '@/state'
4 | import { voices } from '@/constants'
5 | import { Select } from 'antd'
6 |
7 | const VoiceSettingModal: React.FC = () => {
8 | const { t } = useTranslation()
9 | const [voiceId, setVoiceId] = useAtom(voiceIdAtom)
10 |
11 | const handleOptionChange = (value: string) => {
12 | setVoiceId(value)
13 | }
14 |
15 | return (
16 |
17 |
20 |
27 |
28 | )
29 | }
30 |
31 | export default VoiceSettingModal
32 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from 'next/document'
2 | import Script from 'next/script'
3 |
4 | export default function Document() {
5 | const GOOGLE_ANALYTICS_ID = process.env.GOOGLE_ANALYTICS_ID
6 | return (
7 |
8 |
9 |
13 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { useTranslation } from 'react-i18next'
3 | // import { ExternalLink } from 'lucide-react'
4 |
5 | const Footer: React.FC = () => {
6 | const { t } = useTranslation()
7 | return (
8 |
29 | )
30 | }
31 |
32 | export default Footer
33 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import 'regenerator-runtime/runtime'
2 | import '../src/globals.css'
3 | import Head from 'next/head'
4 | import type { AppProps } from 'next/app'
5 | import { useEffect } from 'react'
6 | import { Provider } from 'jotai'
7 | import '../src/i18n'
8 |
9 | function MyApp({ Component, pageProps }: AppProps) {
10 | useEffect(() => {
11 | const link = document.createElement('link')
12 | link.href =
13 | 'https://fonts.googleapis.com/css2?family=Varela+Round&display=swap'
14 | link.rel = 'stylesheet'
15 | document.head.appendChild(link)
16 | }, [])
17 |
18 | return (
19 | <>
20 |
21 | TalkBoostAI
22 |
26 |
27 |
28 |
29 |
30 |
31 | >
32 | )
33 | }
34 |
35 | export default MyApp
36 |
--------------------------------------------------------------------------------
/src/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from 'antd'
2 | import { useAtom } from 'jotai'
3 | import { authModalOpenAtom, userInfoModalOpenAtom } from '@/state'
4 |
5 | const { Header } = Layout
6 |
7 | const Navbar: React.FC = () => {
8 | const user = window?.localStorage?.getItem('user')
9 |
10 | const [openAuthModal, setOpenAuthModal] = useAtom(authModalOpenAtom)
11 | const [openUserInfoModal, setOpenUserInfoModal] = useAtom(
12 | userInfoModalOpenAtom
13 | )
14 |
15 | const openModal = () => {
16 | if (!!user) {
17 | setOpenUserInfoModal(true)
18 | } else {
19 | setOpenAuthModal(true)
20 | }
21 | }
22 |
23 | return (
24 |
25 |
31 |
32 | )
33 | }
34 |
35 | export default Navbar
36 |
--------------------------------------------------------------------------------
/src/locales/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "mode": {
3 | "english_communication": "😊 English communication",
4 | "french_communication": "🥐 French communication",
5 | "japanese_communication": "🌸 Japanese communication",
6 | "spanish_communication": "🌮 Spanish communication",
7 | "IELTS_speaking_test": "🎓 IELTS speaking test"
8 | },
9 | "error": {
10 | "input_username": "Please input your username!",
11 | "input_email": "Please input your email!",
12 | "input_password": "Please input your password!"
13 | },
14 | "chat": "Chat",
15 | "in_chat": "In chat",
16 | "send": "Send",
17 | "translate": "Translate",
18 | "use_voice_answer": "Use voice answer",
19 | "new_chat": "New chat",
20 | "register": "Register",
21 | "login": "Login",
22 | "username": "Username",
23 | "email": "Email",
24 | "password": "Password",
25 | "go_to_login": "Go to login",
26 | "go_to_register": "Go to register",
27 | "logout": "Logout",
28 | "user_exists": "User exists",
29 | "history": "History",
30 | "delete": "Delete",
31 | "switch_language": "Switch language",
32 | "voice_model": "Voice model",
33 | "notification": "Voice service is temporarily unavailable",
34 | "tarot_master_link": "Tarot Master"
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SwitchPrimitives from "@radix-ui/react-switch"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ))
25 | Switch.displayName = SwitchPrimitives.Root.displayName
26 |
27 | export { Switch }
28 |
--------------------------------------------------------------------------------
/src/components/User/UserInfo.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { useAtom } from 'jotai'
3 | import { Modal, Button } from 'antd'
4 | import { userInfoModalOpenAtom } from '@/state'
5 | import Avvvatars from 'avvvatars-react'
6 |
7 | const UserInfo: React.FC = () => {
8 | const { t } = useTranslation()
9 | const [open, setOpen] = useAtom(userInfoModalOpenAtom)
10 |
11 | const user = JSON.parse(window.localStorage.getItem('user') ?? '{}')
12 |
13 | const handleLogout = async () => {
14 | window.localStorage.removeItem('user')
15 | try {
16 | await fetch('/api/user/logout', {
17 | method: 'GET',
18 | })
19 | } catch (error) {
20 | console.error('failed to logout', error)
21 | }
22 | setOpen(false)
23 | }
24 |
25 | return (
26 | setOpen(false)} footer={null}>
27 |
28 |
29 |
{user?.name}
30 |
{user?.email}
31 |
34 |
35 |
36 | )
37 | }
38 |
39 | export default UserInfo
40 |
--------------------------------------------------------------------------------
/src/components/Settings.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Switch } from '@/components/ui/switch'
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogHeader,
7 | DialogTitle,
8 | } from '@/components/ui/dialog'
9 |
10 | type SettingsProps = {
11 | isTTSEnabled: boolean
12 | onTTSToggle: (enabled: boolean) => void
13 | onClose: () => void
14 | }
15 |
16 | const Settings: React.FC = ({
17 | isTTSEnabled,
18 | onTTSToggle,
19 | onClose,
20 | }) => {
21 | return (
22 |
41 | )
42 | }
43 |
44 | export default Settings
45 |
--------------------------------------------------------------------------------
/app/api/user/login/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 | import { PrismaClient } from '@prisma/client'
3 | import jwt from 'jsonwebtoken'
4 | import bcrypt from 'bcrypt'
5 |
6 | export async function POST(req: Request) {
7 | const prisma = new PrismaClient()
8 |
9 | try {
10 | const { username, password } = await req.json()
11 | const user = await prisma.user.findUnique({
12 | where: {
13 | name: username,
14 | },
15 | })
16 | if (!user) {
17 | return NextResponse.json(
18 | { statusText: 'Invalid username' },
19 | { status: 401 }
20 | )
21 | }
22 |
23 | if (!bcrypt.compareSync(password, user.password)) {
24 | return NextResponse.json(
25 | { statusText: 'Invalid email or password' },
26 | { status: 401 }
27 | )
28 | }
29 | const { name, email } = user
30 | const token = jwt.sign({ name: user.name }, process.env.JWT_SECRET)
31 | const response = NextResponse.json(
32 | { user: { name, email } },
33 | { status: 200 }
34 | )
35 |
36 | response.cookies.set({
37 | name: 'token',
38 | value: token,
39 | httpOnly: true,
40 | maxAge: 1000 * 60 * 60 * 24 * 7,
41 | })
42 |
43 | return response
44 | } catch (err) {
45 | return NextResponse.json({ error: 'login failed' }, { status: 500 })
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/app/api/user/register/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 | import { PrismaClient, Prisma } from '@prisma/client'
3 | import jwt from 'jsonwebtoken'
4 | import bcrypt from 'bcrypt'
5 |
6 | export async function POST(req: Request) {
7 | const prisma = new PrismaClient()
8 |
9 | try {
10 | const { username: name, password, email } = await req.json()
11 |
12 | const salt = bcrypt.genSaltSync(10)
13 | const hashedPassword = await bcrypt.hash(password, salt)
14 | const user = await prisma.user.create({
15 | data: {
16 | name,
17 | password: hashedPassword,
18 | email,
19 | },
20 | })
21 |
22 | const token = jwt.sign({ name: user.name }, process.env.JWT_SECRET)
23 | const response = NextResponse.json({ user }, { status: 201 })
24 |
25 | response.cookies.set({
26 | name: 'token',
27 | value: token,
28 | httpOnly: true,
29 | maxAge: 1000 * 60 * 60 * 24 * 7,
30 | })
31 |
32 | return response
33 | } catch (err) {
34 | if (err instanceof Prisma.PrismaClientKnownRequestError) {
35 | if (err.code === 'P2002') {
36 | return NextResponse.json(
37 | {
38 | error: 'Username already exists',
39 | },
40 | { status: 409 }
41 | )
42 | }
43 | } else {
44 | return NextResponse.json(
45 | { error: 'Registration failed' },
46 | { status: 500 }
47 | )
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/apis/azureTTS.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SpeechConfig,
3 | AudioConfig,
4 | SpeechRecognizer,
5 | } from 'microsoft-cognitiveservices-speech-sdk'
6 | import * as speechSDK from 'microsoft-cognitiveservices-speech-sdk'
7 |
8 | export const azureSpeechToText = () => {
9 | const speechConfig = SpeechConfig.fromSubscription(
10 | process.env.NEXT_PUBLIC_AZURE_SPEECH_KEY,
11 | process.env.NEXT_PUBLIC_AZURE_SPEECH_REGION
12 | )
13 | const audioConfig = AudioConfig.fromDefaultMicrophoneInput()
14 | const recognizer = new SpeechRecognizer(speechConfig, audioConfig)
15 | return recognizer
16 | }
17 |
18 | export const azureSynthesizeSpeech = (
19 | text: string,
20 | voiceId = 'en-US-AvaMultilingualNeural'
21 | ): speechSDK.SpeakerAudioDestination => {
22 | const speechConfig = speechSDK.SpeechConfig.fromSubscription(
23 | process.env.NEXT_PUBLIC_AZURE_SPEECH_KEY,
24 | process.env.NEXT_PUBLIC_AZURE_SPEECH_REGION
25 | )
26 | speechConfig.speechSynthesisVoiceName = voiceId
27 | const player = new speechSDK.SpeakerAudioDestination()
28 | const audioConfig = speechSDK.AudioConfig.fromSpeakerOutput(player)
29 | const speechSynthesizer = new speechSDK.SpeechSynthesizer(
30 | speechConfig,
31 | audioConfig
32 | )
33 | speechSynthesizer.speakTextAsync(
34 | text,
35 | (result) => {
36 | speechSynthesizer.close()
37 | },
38 | (error) => {
39 | console.log(error)
40 | speechSynthesizer.close()
41 | }
42 | )
43 | return player
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/PlayerBtn.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { useAtom } from 'jotai'
3 | import { Button } from 'antd'
4 | import { openAiCount, voiceIdAtom } from '@/state'
5 | import { PlayCircleOutlined, PauseCircleOutlined } from '@ant-design/icons'
6 | import { azureSynthesizeSpeech } from '@/apis/azureTTS'
7 | import { isSafari } from 'react-device-detect'
8 |
9 | const PlayerBtn: React.FC<{
10 | content: string
11 | index: number
12 | }> = ({ content, index }) => {
13 | const [audio, setAudio] = useState(null)
14 | const [isPlaying, setIsPlaying] = useState(false)
15 | const [aiCount] = useAtom(openAiCount)
16 | const [voiceId] = useAtom(voiceIdAtom)
17 |
18 | const Icon = isPlaying ? PauseCircleOutlined : PlayCircleOutlined
19 |
20 | const handlePlay = () => {
21 | const player = azureSynthesizeSpeech(content, voiceId)
22 | player.onAudioEnd = () => {
23 | setIsPlaying(false)
24 | }
25 | setAudio(player)
26 | }
27 |
28 | const handlePause = () => {
29 | if (audio) {
30 | audio.pause()
31 | setIsPlaying(false)
32 | }
33 | }
34 |
35 | const handleClick = () => {
36 | if (isPlaying) {
37 | handlePause()
38 | } else {
39 | handlePlay()
40 | setIsPlaying(true)
41 | }
42 | }
43 |
44 | useEffect(() => {
45 | if (aiCount === index) {
46 | handlePlay()
47 | if (!isSafari) {
48 | setIsPlaying(true)
49 | }
50 | } else {
51 | handlePause()
52 | }
53 | }, [aiCount])
54 |
55 | return } />
56 | }
57 |
58 | export default PlayerBtn
59 |
--------------------------------------------------------------------------------
/src/components/RecordBtn.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import SpeechRecognition, {
4 | useSpeechRecognition,
5 | } from 'react-speech-recognition'
6 | import { Button } from 'antd'
7 |
8 | interface RecordBtcProps {
9 | setInput: (input: string) => void
10 | }
11 |
12 | const RecordBtn: React.FC = ({ setInput }) => {
13 | const { t } = useTranslation()
14 | const {
15 | transcript,
16 | listening,
17 | resetTranscript,
18 | browserSupportsSpeechRecognition,
19 | } = useSpeechRecognition()
20 |
21 | const handleRecord = () => {
22 | if (!browserSupportsSpeechRecognition) {
23 | console.log("browser doesn't support speech recognition")
24 | return
25 | }
26 | listening
27 | ? SpeechRecognition.stopListening()
28 | : SpeechRecognition.startListening()
29 | }
30 |
31 | useEffect(() => {
32 | if (transcript) {
33 | setInput(transcript)
34 | }
35 | }, [transcript])
36 |
37 | return (
38 |
39 |
52 |
53 | )
54 | }
55 |
56 | export default RecordBtn
57 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "talk-boost-ai",
3 | "version": "2.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "prisma generate && next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@ant-design/icons": "^5.0.1",
13 | "@prisma/client": "5.0.0",
14 | "@radix-ui/react-dialog": "^1.1.1",
15 | "@radix-ui/react-icons": "^1.3.0",
16 | "@radix-ui/react-slot": "^1.1.0",
17 | "@radix-ui/react-switch": "^1.1.0",
18 | "antd": "^5.4.5",
19 | "autoprefixer": "^10.4.14",
20 | "avvvatars-react": "^0.4.2",
21 | "axios": "^1.3.4",
22 | "bcrypt": "^5.1.1",
23 | "class-variance-authority": "^0.7.0",
24 | "clsx": "^2.1.1",
25 | "dayjs": "^1.11.9",
26 | "i18next": "^23.5.1",
27 | "i18next-browser-languagedetector": "^7.1.0",
28 | "jotai": "^2.0.4",
29 | "jsonwebtoken": "^9.0.1",
30 | "lucide-react": "^0.428.0",
31 | "microsoft-cognitiveservices-speech-sdk": "1.32.0",
32 | "next": "13.2.4",
33 | "postcss-flexbugs-fixes": "^5.0.2",
34 | "postcss-preset-env": "^8.3.0",
35 | "react": "18.2.0",
36 | "react-device-detect": "^2.2.3",
37 | "react-dom": "18.2.0",
38 | "react-i18next": "^13.2.2",
39 | "react-markdown": "^8.0.7",
40 | "react-speech-recognition": "^3.10.0",
41 | "regenerator-runtime": "^0.13.11",
42 | "tailwind-merge": "^2.5.2",
43 | "tailwindcss": "^3.3.1",
44 | "tailwindcss-animate": "^1.0.7"
45 | },
46 | "devDependencies": {
47 | "@types/node": "18.15.11",
48 | "@types/react": "18.0.33",
49 | "@types/react-dom": "18.0.11",
50 | "eslint": "8.37.0",
51 | "eslint-config-next": "13.2.4",
52 | "eslint-config-prettier": "^9.0.0",
53 | "prettier": "^3.0.3",
54 | "prettier-plugin-tailwindcss": "^0.5.4",
55 | "prisma": "^5.0.0",
56 | "typescript": "5.0.3"
57 | },
58 | "packageManager": "pnpm@8.6.5+sha512.9b3b5fbe7789978a3d80259f42514a8a8ea05588496c56b3594541728989f6eff2b29c127075313325afc4e79dd5ae78deba28f9a7888e9e554241cea1358502"
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/User/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { Form, Input, Button } from 'antd'
3 | import { useAtom } from 'jotai'
4 | import { authModalOpenAtom } from '@/state'
5 |
6 | interface LoginFormProps {
7 | toRegister: () => void
8 | }
9 |
10 | const LoginForm: React.FC = ({ toRegister }) => {
11 | const { t } = useTranslation()
12 | const [open, setOpen] = useAtom(authModalOpenAtom)
13 |
14 | const login = async (values: any) => {
15 | try {
16 | const res = await fetch('/api/user/login', {
17 | method: 'POST',
18 | headers: {
19 | 'Content-Type': 'application/json',
20 | },
21 | body: JSON.stringify(values),
22 | })
23 |
24 | if (res.status === 200) {
25 | const { user } = await res.json()
26 | window.localStorage.setItem('user', JSON.stringify(user))
27 | setOpen(false)
28 | } else {
29 | console.log(res.statusText)
30 | }
31 | } catch (error) {
32 | console.error('failed to login', error)
33 | }
34 | }
35 |
36 | return (
37 |
48 |
49 |
50 |
55 |
56 |
57 |
58 |
59 |
62 |
65 |
66 |
67 | )
68 | }
69 |
70 | export default LoginForm
71 |
--------------------------------------------------------------------------------
/src/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 222.2 84% 4.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 222.2 84% 4.9%;
13 | --primary: 222.2 47.4% 11.2%;
14 | --primary-foreground: 210 40% 98%;
15 | --secondary: 210 40% 96.1%;
16 | --secondary-foreground: 222.2 47.4% 11.2%;
17 | --muted: 210 40% 96.1%;
18 | --muted-foreground: 215.4 16.3% 46.9%;
19 | --accent: 210 40% 96.1%;
20 | --accent-foreground: 222.2 47.4% 11.2%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 210 40% 98%;
23 | --border: 214.3 31.8% 91.4%;
24 | --input: 214.3 31.8% 91.4%;
25 | --ring: 222.2 84% 4.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 222.2 84% 4.9%;
36 | --foreground: 210 40% 98%;
37 | --card: 222.2 84% 4.9%;
38 | --card-foreground: 210 40% 98%;
39 | --popover: 222.2 84% 4.9%;
40 | --popover-foreground: 210 40% 98%;
41 | --primary: 210 40% 98%;
42 | --primary-foreground: 222.2 47.4% 11.2%;
43 | --secondary: 217.2 32.6% 17.5%;
44 | --secondary-foreground: 210 40% 98%;
45 | --muted: 217.2 32.6% 17.5%;
46 | --muted-foreground: 215 20.2% 65.1%;
47 | --accent: 217.2 32.6% 17.5%;
48 | --accent-foreground: 210 40% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 210 40% 98%;
51 | --border: 217.2 32.6% 17.5%;
52 | --input: 217.2 32.6% 17.5%;
53 | --ring: 212.7 26.8% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | body {
67 | @apply bg-background text-foreground;
68 | }
69 | }
--------------------------------------------------------------------------------
/src/components/Onboarding.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from 'antd'
2 | import { useTranslation } from 'react-i18next'
3 | import { IELTS_SPEAKING_TEST } from '@/constants'
4 |
5 | interface OnboardingProps {
6 | setMessages: Function
7 | sending: boolean
8 | setSending: Function
9 | }
10 |
11 | const languages = ['English', 'French', 'Japanese', 'Spanish']
12 |
13 | const getPrompt = (language: string) => {
14 | return `You are an ${language} teacher, please help me practice daily ${language} communication. Find interesting topics to chat about and respond in a friendly way. Please keep your answer concise and to the point, trying to be around 2-3 sentences. If I make any mistakes, please point them out and correct them. Please communicate with me in ${language}.`
15 | }
16 |
17 | const Onboarding: React.FC = ({
18 | setMessages,
19 | sending,
20 | setSending,
21 | }) => {
22 | const { t } = useTranslation()
23 | const onClick = (prompt: string) => {
24 | setMessages((prevMessages: any[]) => [
25 | ...prevMessages,
26 | {
27 | role: 'user',
28 | content: prompt,
29 | },
30 | ])
31 | setSending(true)
32 | }
33 |
34 | return (
35 |
36 |
Talk Boost AI
37 |
38 |
45 | {languages.map((language, index) => {
46 | const prompt = getPrompt(language)
47 | return (
48 |
57 | )
58 | })}
59 |
60 |
61 | )
62 | }
63 |
64 | export default Onboarding
65 |
--------------------------------------------------------------------------------
/src/constants/prompts.ts:
--------------------------------------------------------------------------------
1 | export const SYSTEM_MESSAGE = 'You are a professional education assistant.'
2 | export const ENGLISH_TEACHER =
3 | 'You are an English teacher, please help me practice daily English communication. Find interesting topics to chat about and respond in a friendly way. Please keep your answer concise and to the point, trying to be around 2-3 sentences. If I make any mistakes, please point them out and correct them. You are an Japanese teacher, please help me practice daily Japanese communication. Find interesting topics to chat about and respond in a friendly way. Please keep your answer concise and to the point, trying to be around 2-3 sentences. If I make any mistakes, please point them out and correct them. Please communicate with me in English.'
4 | export const TRANSLATE_SYSTEM_PROMPT =
5 | 'You are a translation engine, you can only translate text and cannot interpret it, and do not explain.'
6 | export const TRANSLATE_PROMPT = 'Translate the text to Chinese: '
7 | export const IELTS_SPEAKING_TEST =
8 | "I am currently preparing for the IELTS speaking test. You are now playing the role of an IELTS speaking examiner. Try to follow the standards and procedures of the real IELTS speaking test and randomly create questions to help users practice. You should always use the language style of an IELTS-speaking examiner in a conversation with the user. When replying to messages, use a clear formatting style, referring to Markdown syntax. You can improve the readability of your responses by using line breaks, paragraphs, lists, indentation, and highlighting. Please communicate with the user in English throughout the entire conversation.Cue Card Topic Categories for PART 2: personal experiences, people, places, events, hobbies, technology, nature, education, work, social issues, objects, food and cuisine, transportation, fashion and style, health and fitness, music, childhood, relationships, entertainment, cultural practices. In the PART 2 segment, please randomly select a category from the Cue Card Topic Categories for PART 2 and generate a Cue Card Topic based on that category. After completing the PART 3 section, provide an overall evaluation of the user based on all their answers. Now, let's start with PART 1, asking one question at a time and waiting for the user's response before moving on to the next question."
9 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | prefix: "",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | keyframes: {
61 | "accordion-down": {
62 | from: { height: "0" },
63 | to: { height: "var(--radix-accordion-content-height)" },
64 | },
65 | "accordion-up": {
66 | from: { height: "var(--radix-accordion-content-height)" },
67 | to: { height: "0" },
68 | },
69 | },
70 | animation: {
71 | "accordion-down": "accordion-down 0.2s ease-out",
72 | "accordion-up": "accordion-up 0.2s ease-out",
73 | },
74 | },
75 | },
76 | plugins: [require("tailwindcss-animate")],
77 | }
--------------------------------------------------------------------------------
/src/components/AIPanel.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { useAtom } from 'jotai'
3 | import { Button, Divider, message } from 'antd'
4 | import { requestChatAI } from '@/apis/chatAI'
5 | import { openVoiceAtom } from '@/state'
6 | import { TRANSLATE_SYSTEM_PROMPT, TRANSLATE_PROMPT } from '@/constants'
7 | import { TranslationOutlined, CopyOutlined } from '@ant-design/icons'
8 | import PlayerBtn from './PlayerBtn'
9 | import ReactMarkdown from 'react-markdown'
10 | import Avvvatars from 'avvvatars-react'
11 | import { isMobile } from 'react-device-detect'
12 |
13 | const AIPanel: React.FC<{
14 | index: number
15 | content: string
16 | sending: boolean
17 | }> = ({ content, index }) => {
18 | const [openVoice] = useAtom(openVoiceAtom)
19 | const [translating, setTranslating] = useState(false)
20 | const [translateContent, setTranslateContent] = useState(null)
21 |
22 | const [messageApi, contextHolder] = message.useMessage()
23 |
24 | const handleTranslate = async () => {
25 | const translateMessages = [
26 | {
27 | role: 'system',
28 | content: TRANSLATE_SYSTEM_PROMPT,
29 | },
30 | {
31 | role: 'user',
32 | content: `${TRANSLATE_PROMPT}${content}`,
33 | },
34 | ]
35 |
36 | setTranslating(true)
37 | try {
38 | const aiResponse = await requestChatAI(translateMessages)
39 | if (aiResponse) {
40 | setTranslateContent(aiResponse)
41 | }
42 | } catch (error) {
43 | console.error(error)
44 | }
45 | setTranslating(false)
46 | }
47 |
48 | const handleCopy = () => {
49 | navigator.clipboard.writeText(content)
50 | messageApi.info('Copy successfully!')
51 | }
52 |
53 | return (
54 |
55 |
58 |
59 | {contextHolder}
60 |
61 |
{content}
62 | {translateContent && (
63 | <>
64 |
65 |
{translateContent}
66 | >
67 | )}
68 |
69 |
70 |
71 | {openVoice &&
}
72 |
} />
73 |
}
79 | />
80 |
81 |
82 |
83 | )
84 | }
85 |
86 | export default AIPanel
87 |
--------------------------------------------------------------------------------
/src/components/User/RegisterForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { Form, Input, Button, message } from 'antd'
4 | import Avvvatars from 'avvvatars-react'
5 | import { useAtom } from 'jotai'
6 | import { authModalOpenAtom } from '@/state'
7 |
8 | interface RegisterFormProps {
9 | toLogin: () => void
10 | }
11 |
12 | const RegisterForm: React.FC = ({ toLogin }) => {
13 | const { t } = useTranslation()
14 | const [username, setUsername] = useState('')
15 | const [, setOpen] = useAtom(authModalOpenAtom)
16 | const [messageApi, contextHolder] = message.useMessage()
17 |
18 | const register = async (values: any) => {
19 | try {
20 | const res = await fetch('/api/user/register', {
21 | method: 'POST',
22 | headers: {
23 | 'Content-Type': 'application/json',
24 | },
25 | body: JSON.stringify(values),
26 | })
27 | if (res.status === 409) {
28 | messageApi.error(t('user_exists'))
29 | return
30 | }
31 | if (res.status === 201) {
32 | const { user } = await res.json()
33 | window.localStorage.setItem('user', JSON.stringify(user))
34 | setOpen(false)
35 | } else {
36 | console.log(res.statusText)
37 | }
38 | } catch (error) {
39 | console.error('failed to register', error)
40 | }
41 | }
42 |
43 | return (
44 | <>
45 | {contextHolder}
46 |
57 |
58 |
setUsername(e.target.value)}
61 | />
62 |
63 |
64 |
65 |
70 |
71 |
72 |
77 |
78 |
79 |
80 |
81 |
84 |
87 |
88 |
89 | >
90 | )
91 | }
92 |
93 | export default RegisterForm
94 |
--------------------------------------------------------------------------------
/src/components/MessageItem.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import ReactMarkdown from 'react-markdown'
3 | import { Volume2, VolumeX, Copy, Languages, Loader } from 'lucide-react'
4 | import { Message } from '@/components/TalkBoost'
5 | import { TRANSLATE_SYSTEM_PROMPT, TRANSLATE_PROMPT } from '@/constants'
6 | import { requestChatAI } from '@/apis/chatAI'
7 |
8 | interface Props {
9 | index: number
10 | message: Message
11 | isSpeaking: boolean
12 | currentSpeakingIndex: number | null
13 | toggleSpeaking: (message: string, index: number) => void
14 | }
15 |
16 | const MessageItem: React.FC = ({
17 | index,
18 | message,
19 | isSpeaking,
20 | currentSpeakingIndex,
21 | toggleSpeaking,
22 | }) => {
23 | const { content, role } = message
24 | const isAssistant = role === 'assistant'
25 |
26 | const [translating, setTranslating] = useState(false)
27 | const [translateContent, setTranslateContent] = useState(null)
28 |
29 | const handleTranslate = async () => {
30 | const translateMessages = [
31 | {
32 | role: 'system',
33 | content: TRANSLATE_SYSTEM_PROMPT,
34 | },
35 | {
36 | role: 'user',
37 | content: `${TRANSLATE_PROMPT}${content}`,
38 | },
39 | ]
40 |
41 | setTranslating(true)
42 | try {
43 | const aiResponse = await requestChatAI(translateMessages)
44 | if (aiResponse) {
45 | setTranslateContent(aiResponse)
46 | }
47 | } catch (error) {
48 | console.error('Error translating message:', error)
49 | } finally {
50 | setTranslating(false)
51 | }
52 | }
53 |
54 | const handleCopy = () => {
55 | navigator.clipboard.writeText(content)
56 | }
57 |
58 | const buttonClass =
59 | 'flex h-7 w-7 items-center justify-center rounded bg-transparent text-gray-600 transition duration-200 hover:bg-white hover:text-gray-800'
60 |
61 | return (
62 |
63 |
68 |
{content}
69 | {translateContent && (
70 | <>
71 |
72 |
{translateContent}
73 | >
74 | )}
75 | {isAssistant && (
76 |
77 |
87 |
100 |
103 |
104 | )}
105 |
106 |
107 | )
108 | }
109 |
110 | export default MessageItem
111 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { X } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Dialog = DialogPrimitive.Root
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger
10 |
11 | const DialogPortal = DialogPrimitive.Portal
12 |
13 | const DialogClose = DialogPrimitive.Close
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ))
52 | DialogContent.displayName = DialogPrimitive.Content.displayName
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | )
66 | DialogHeader.displayName = "DialogHeader"
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | )
80 | DialogFooter.displayName = "DialogFooter"
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ))
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ))
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogClose,
114 | DialogTrigger,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription,
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/HistoryPanel.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { setLocal, getLocal, removeLocal } from '@/utils'
4 | import { isMobile } from 'react-device-detect'
5 | import { useAtom } from 'jotai'
6 | import { Drawer, Space } from 'antd'
7 | import { recordNowHistory, recordNowHistoryName } from '@/state/settings'
8 | import dayjs from 'dayjs'
9 | import { DoubleRightOutlined, CloseOutlined } from '@ant-design/icons'
10 | import { SYSTEM_MESSAGE } from '@/constants'
11 |
12 | const HistoryPanel = ({ msgList }, ref) => {
13 | const { t, i18n } = useTranslation()
14 | const [historyList, setHistoryList] = useState([])
15 | const [currentChat, setCurrentChat] = useState(0)
16 | const [recordCount, setRecordCount] = useAtom(recordNowHistory)
17 | const [recordName, setRecordName] = useAtom(recordNowHistoryName)
18 | const [recordFlags, setRecordFlags] = useState(false)
19 | const [open, setOpen] = useState(false)
20 |
21 | const handleChangeLanguage = () => {
22 | const currentLanguage = i18n.language
23 | const newLanguage = currentLanguage === 'en' ? 'zh' : 'en'
24 | i18n.changeLanguage(newLanguage)
25 | }
26 |
27 | useImperativeHandle(ref, () => ({
28 | handleAdd: handleAddHistory,
29 | }))
30 |
31 | useEffect(() => {
32 | if (!getLocal('history')) {
33 | const initHistory = [
34 | {
35 | name: `${dayjs(new Date()).format('YYYY-MM-DD')}-${recordCount}`,
36 | id: 1,
37 | details: msgList,
38 | },
39 | ]
40 | setLocal('history', initHistory)
41 | setHistoryList(initHistory)
42 | } else {
43 | let history = getLocal('history')
44 | if (!recordName) {
45 | setRecordName(history[0].name)
46 | }
47 | setHistoryList(history)
48 | handleDataRecord(msgList)
49 | setRecordFlags(true)
50 | }
51 | }, [msgList])
52 |
53 | useEffect(() => {
54 | if (historyList.length > 0 && recordFlags) {
55 | setLocal('history', historyList)
56 | setRecordFlags(false)
57 | }
58 | if (historyList.length === 0) {
59 | removeLocal('history')
60 | }
61 | }, [historyList, recordFlags])
62 |
63 | useEffect(() => {
64 | setHistoryList((currentChat) => {
65 | currentChat.forEach((item) => {
66 | if (item.name === recordName) {
67 | item.details = []
68 | }
69 | })
70 | let newArr = currentChat.slice()
71 | return newArr
72 | })
73 | }, [recordName])
74 |
75 | const handleDataRecord = (list) => {
76 | if (list.length > 0) {
77 | setHistoryList((currentChat) => {
78 | currentChat.forEach((item) => {
79 | if (item.name === recordName) {
80 | item.details = list
81 | }
82 | })
83 | let newArr = currentChat.slice()
84 | return newArr
85 | })
86 | }
87 | }
88 | const handleItemClick = (item, index) => {
89 | setCurrentChat(index)
90 | setRecordName(() => item.name)
91 | }
92 | const handleAddHistory = () => {
93 | let date = dayjs(new Date()).format('YYYY-MM-DD')
94 | setHistoryList((pre) => {
95 | return [
96 | ...pre,
97 | {
98 | name: `${date}-${recordCount + 1}`,
99 | id: pre.length + 1,
100 | details: [
101 | {
102 | role: 'system',
103 | content: SYSTEM_MESSAGE,
104 | },
105 | ],
106 | },
107 | ]
108 | })
109 | setRecordCount(() => recordCount + 1)
110 | setRecordName(`${date}-${recordCount + 1}`)
111 | setCurrentChat(historyList.length)
112 | setRecordFlags(true)
113 | }
114 | const handleDeleteChat = (index) => {
115 | setRecordFlags(true)
116 | setHistoryList((value) => {
117 | const copy = [...value]
118 | copy.splice(index, 1)
119 | return copy
120 | })
121 | setCurrentChat(
122 | index + 1 >= historyList.length ? historyList.length - 2 : index + 1
123 | )
124 | }
125 | const showDrawer = () => {
126 | setOpen(true)
127 | }
128 |
129 | const onClose = () => {
130 | setOpen(false)
131 | }
132 |
133 | return (
134 | <>
135 | {!isMobile ? (
136 |
137 |
138 | {historyList.map((item, index) => (
139 |
handleItemClick(item, index)}
145 | >
146 | {item.name}
147 |
148 | handleDeleteChat(index)}>
149 | {t('delete')}
150 |
151 |
152 |
153 | ))}
154 |
155 |
156 |
160 | {`💬 ${t('new_chat')}`}
161 |
162 |
166 | {`🌐 ${t('switch_language')}`}
167 |
168 |
169 | ) : (
170 |
171 |
172 |
173 |
174 |
183 |
184 |
185 | }
186 | >
187 |
188 | {historyList.map((item, index) => (
189 |
handleItemClick(item, index)}
197 | >
198 | {item.name}
199 |
200 | handleDeleteChat(index)}>
201 | {t('delete')}
202 |
203 |
204 |
205 | ))}
206 |
207 |
208 |
209 | )}
210 | >
211 | )
212 | }
213 | export default forwardRef(HistoryPanel)
214 |
--------------------------------------------------------------------------------
/src/components/Content.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useEffect,
3 | useState,
4 | KeyboardEventHandler,
5 | useRef,
6 | useLayoutEffect,
7 | } from 'react'
8 | import { requestChatAI } from '@/apis/chatAI'
9 | import { azureSpeechToText } from '@/apis/azureTTS'
10 | import { isIOS, getLocal } from '@/utils'
11 | import { PlusOutlined } from '@ant-design/icons'
12 | import { Input, Button } from 'antd'
13 | import AIPanel from './AIPanel'
14 | import { useAtom } from 'jotai'
15 | import { openAiCount, openVoiceAtom } from '@/state'
16 | import { SYSTEM_MESSAGE } from '@/constants'
17 | import HistoryPanel from './HistoryPanel'
18 | import UserPanel from './UserPanel'
19 | import Onboarding from './Onboarding'
20 | import { recordNowHistoryName } from '@/state/settings'
21 | import { isMobile } from 'react-device-detect'
22 | import { useTranslation } from 'react-i18next'
23 | import { SpeakerModerateIcon, SpeakerOffIcon } from '@radix-ui/react-icons'
24 | import VoiceSetting from './VoiceSetting'
25 |
26 | const { TextArea } = Input
27 |
28 | const Content: React.FC = () => {
29 | const { t } = useTranslation()
30 | const [sending, setSending] = useState(false)
31 | const [input, setInput] = useState('')
32 | const [messages, setMessages] = useState([
33 | {
34 | role: 'system',
35 | content: SYSTEM_MESSAGE,
36 | },
37 | ])
38 |
39 | const [response, setResponse] = useState('')
40 | const [recordFlag, setRecordFlag] = useState(false)
41 | const [listening, setListening] = useState(false)
42 | const [recognizer, setRecognizer] = useState({})
43 | const [recordName, setRecordName] = useAtom(recordNowHistoryName)
44 | const [openVoice, setOpenVoice] = useAtom(openVoiceAtom)
45 |
46 | // auto scroll
47 | const messagesEndRef = useRef(null)
48 | const [autoScroll, setAutoScroll] = useState(false)
49 | const [waiting, setWaiting] = useState(false)
50 | const [, setAiCount] = useAtom(openAiCount)
51 | const historyRef = useRef(null)
52 | const handleSend = () => {
53 | const input_json = { role: 'user', content: input }
54 | setMessages((prevMessages) => [...prevMessages, input_json])
55 | setInput('')
56 | setSending(true)
57 | }
58 |
59 | useEffect(() => {
60 | if (Object.keys(recognizer).length === 0) {
61 | let res = azureSpeechToText()
62 | setRecognizer(res)
63 | }
64 | }, [recognizer])
65 |
66 | const handleRecord = () => {
67 | if (!recordFlag) {
68 | setWaiting(true)
69 | recognizer.startContinuousRecognitionAsync(
70 | () => {
71 | setWaiting(false)
72 | },
73 | (err) => {
74 | recognizer.stopContinuousRecognitionAsync()
75 | }
76 | )
77 | recognizer.recognized = function (s, e) {
78 | if (e.result.text !== undefined) {
79 | let result = e.result.text
80 | setInput((pre) => pre + result)
81 | }
82 | }
83 | recognizer.sessionStopped = (s, e) => {
84 | setWaiting(false)
85 | recognizer.stopContinuousRecognitionAsync()
86 | }
87 | } else {
88 | recognizer.stopContinuousRecognitionAsync()
89 | }
90 |
91 | setRecordFlag(!recordFlag)
92 | setListening(!listening)
93 | }
94 |
95 | const handleKeyDown: KeyboardEventHandler = (event) => {
96 | if (event.key === 'Enter' && !event.shiftKey) {
97 | event.preventDefault()
98 | if (input.length === 0) return
99 | handleSend()
100 | } else if (event.key === 'Enter' && event.shiftKey) {
101 | event.preventDefault()
102 | setInput(input + '\n')
103 | }
104 | }
105 |
106 | const handleGenAIResponse = async (messages) => {
107 | try {
108 | const aiResponse = await requestChatAI(messages)
109 | if (aiResponse) {
110 | setResponse(aiResponse)
111 | }
112 | } catch (error) {
113 | console.error(error)
114 | }
115 | }
116 |
117 | const addNewHistory = () => {
118 | historyRef.current.handleAdd()
119 | }
120 |
121 | const handleOpenVoice = () => {
122 | setOpenVoice(!openVoice)
123 | }
124 |
125 | useEffect(() => {
126 | if (response.length !== 0 && response !== 'undefined') {
127 | setMessages((prevMessages) => [
128 | ...prevMessages,
129 | { role: 'assistant', content: response },
130 | ])
131 | setSending(false)
132 | setAiCount(messages.slice(2).length)
133 | }
134 | }, [response])
135 |
136 | useEffect(() => {
137 | if (sending && messages.length > 0) {
138 | handleGenAIResponse(messages)
139 | }
140 | }, [sending])
141 |
142 | useEffect(() => {
143 | let historyList = getLocal('history')
144 | let currentList = historyList?.find((item) => item.name === recordName)
145 | ?.details || [
146 | {
147 | role: 'system',
148 | content: SYSTEM_MESSAGE,
149 | },
150 | ]
151 | setMessages(currentList)
152 | }, [recordName])
153 |
154 | useLayoutEffect(() => {
155 | setTimeout(() => {
156 | const dom = messagesEndRef.current
157 | if (dom && !isIOS() && autoScroll) {
158 | dom.scrollIntoView({ behavior: 'smooth', block: 'end' })
159 | }
160 | }, 500)
161 | })
162 |
163 | const displayMessages = messages.slice(2)
164 | const isOnboarding = displayMessages.length === 0
165 |
166 | return (
167 | <>
168 |
169 |
170 |
171 | {!isOnboarding ? (
172 | <>
173 | {displayMessages.map(({ role, content }, index) =>
174 | role === 'user' ? (
175 |
176 | ) : (
177 |
183 | )
184 | )}
185 |
186 | >
187 | ) : (
188 |
193 | )}
194 |
195 |
196 | {!isOnboarding && (
197 |
198 |
:
}
203 | />
204 |
205 | {isMobile && (
206 |
210 | )}
211 |
212 | )}
213 |
214 | {!isOnboarding && (
215 |
245 | )}
246 |
247 | >
248 | )
249 | }
250 |
251 | export default Content
252 |
--------------------------------------------------------------------------------
/src/components/TalkBoost.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from 'react'
2 | import { Send, Mic, MicOff, Loader } from 'lucide-react'
3 | import {
4 | PlusIcon,
5 | HamburgerMenuIcon,
6 | TrashIcon,
7 | GearIcon,
8 | } from '@radix-ui/react-icons'
9 | import { Button } from '@/components/ui/button'
10 | import { Input } from 'antd'
11 | import { requestChatAI } from '@/apis/chatAI'
12 | import { azureSynthesizeSpeech, azureSpeechToText } from '@/apis/azureTTS'
13 | import MessageItem from './MessageItem'
14 | import Settings from './Settings'
15 | import Footer from './Footer'
16 | import { SpeakerAudioDestination } from 'microsoft-cognitiveservices-speech-sdk'
17 |
18 | export type Message = {
19 | role: string
20 | content: string
21 | }
22 |
23 | export type Messages = Array
24 |
25 | type Chats = Array<{
26 | id: number
27 | messages: Messages
28 | }>
29 |
30 | const SYSTEM_MESSAGE = {
31 | role: 'system',
32 | content: 'You are a professional education assistant.',
33 | }
34 |
35 | const { TextArea } = Input
36 |
37 | const TalkBoost = () => {
38 | const [chats, setChats] = useState([])
39 | const [currentChatId, setCurrentChatId] = useState(null)
40 | const [messages, setMessages] = useState([SYSTEM_MESSAGE])
41 | const [inputMessage, setInputMessage] = useState('')
42 | const [isSidebarOpen, setIsSidebarOpen] = useState(false)
43 | const [isListening, setIsListening] = useState(false)
44 | const [isLoading, setIsLoading] = useState(false)
45 | const [error, setError] = useState(null)
46 | const sidebarRef = useRef(null)
47 | const inputRef = useRef(null)
48 | const recognitionRef = useRef(null)
49 |
50 | const [isSpeaking, setIsSpeaking] = useState(false)
51 | const [currentSpeakingIndex, setCurrentSpeakingIndex] = useState(null)
52 | const audioRef = useRef(null)
53 |
54 | const [isSettingsOpen, setIsSettingsOpen] = useState(false)
55 | const [isTTSEnabled, setIsTTSEnabled] = useState(true)
56 |
57 | const createNewChat = () => {
58 | setCurrentChatId(null)
59 | setMessages([SYSTEM_MESSAGE])
60 | setIsSidebarOpen(false)
61 | }
62 |
63 | const updateChat = (chatId: number, newMessages: Messages) => {
64 | setChats((prevChats) =>
65 | prevChats.map((chat) =>
66 | chat.id === chatId ? { ...chat, messages: newMessages } : chat
67 | )
68 | )
69 | }
70 |
71 | const clearSpeaking = () => {
72 | if (audioRef.current) {
73 | audioRef.current.pause()
74 | audioRef.current = null
75 | setIsSpeaking(false)
76 | setCurrentSpeakingIndex(null)
77 | }
78 | }
79 |
80 | const handleSendMessage = async () => {
81 | if (inputMessage.trim() === '') return
82 |
83 | setIsLoading(true)
84 | setError(null)
85 | const newUserMessage = { role: 'user', content: inputMessage }
86 | const updatedMessages = [...messages, newUserMessage]
87 | setMessages(updatedMessages)
88 | setInputMessage('')
89 |
90 | let chatId = currentChatId
91 | if (currentChatId === null) {
92 | chatId = Date.now()
93 | setCurrentChatId(chatId)
94 | setChats((prevChats) => [
95 | { id: chatId, messages: updatedMessages },
96 | ...prevChats,
97 | ])
98 | } else {
99 | updateChat(chatId, updatedMessages)
100 | }
101 |
102 | try {
103 | clearSpeaking()
104 |
105 | const aiResponse = await requestChatAI(updatedMessages)
106 | const assistantMessage = { role: 'assistant', content: aiResponse }
107 | const newMessages = [...updatedMessages, assistantMessage]
108 | const speakingIndex = messages.length
109 | setMessages(newMessages)
110 | updateChat(chatId, newMessages)
111 |
112 | if (isTTSEnabled) {
113 | // 合成语音后会自动播放
114 | const player = azureSynthesizeSpeech(aiResponse)
115 | player.onAudioStart = () => {
116 | setIsSpeaking(true)
117 | setCurrentSpeakingIndex(speakingIndex)
118 | }
119 | player.onAudioEnd = () => {
120 | setIsSpeaking(false)
121 | setCurrentSpeakingIndex(null)
122 | }
123 | audioRef.current = player
124 | }
125 | } catch (err) {
126 | setError(
127 | 'Failed to get AI response or synthesize speech. Please try again.'
128 | )
129 | console.error('Error getting AI response:', err)
130 | } finally {
131 | setIsLoading(false)
132 | }
133 | }
134 |
135 | const handlePressEnter = (
136 | event: React.KeyboardEvent
137 | ) => {
138 | if (event.key === 'Enter' && !event.shiftKey) {
139 | event.preventDefault()
140 | handleSendMessage()
141 | }
142 | }
143 |
144 | const handleDeleteChat = (chatId: number, event: React.MouseEvent) => {
145 | event.stopPropagation()
146 | setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatId))
147 | if (chatId === currentChatId) {
148 | setCurrentChatId(null)
149 | setMessages([SYSTEM_MESSAGE])
150 | }
151 | }
152 |
153 | const getChatTitle = (chat: { messages: Messages }) => {
154 | const firstUserMessage = chat.messages.find((msg) => msg.role === 'user')
155 | if (firstUserMessage) {
156 | return firstUserMessage.content.length > 30
157 | ? firstUserMessage.content.substring(0, 27) + '...'
158 | : firstUserMessage.content
159 | }
160 | return 'New Conversation'
161 | }
162 |
163 | const toggleSidebar = () => {
164 | setIsSidebarOpen(!isSidebarOpen)
165 | }
166 |
167 | const toggleListening = () => {
168 | if (isListening) {
169 | recognitionRef.current.stopContinuousRecognitionAsync()
170 | setIsListening(false)
171 | } else {
172 | recognitionRef.current.startContinuousRecognitionAsync(
173 | () => {
174 | setIsListening(true)
175 | },
176 | (err) => {
177 | recognitionRef.current.stopContinuousRecognitionAsync()
178 | setIsListening(false)
179 | }
180 | )
181 | recognitionRef.current.recognized = function (s, e) {
182 | if (e.result.text !== undefined) {
183 | let result = e.result.text
184 | setInputMessage((pre) => pre + result)
185 | }
186 | }
187 | recognitionRef.current.sessionStopped = (s, e) => {
188 | setIsListening(false)
189 | recognitionRef.current.stopContinuousRecognitionAsync()
190 | }
191 | }
192 | }
193 |
194 | const toggleSpeaking = (message: string, index: number) => {
195 | const isCurrentSpeakingIndex = currentSpeakingIndex === index
196 |
197 | if (isSpeaking) {
198 | if (isCurrentSpeakingIndex) {
199 | audioRef.current?.pause()
200 | setIsSpeaking(false)
201 | } else {
202 | clearSpeaking()
203 | const newPlayer = azureSynthesizeSpeech(message)
204 | newPlayer.onAudioStart = () => {
205 | setIsSpeaking(true)
206 | setCurrentSpeakingIndex(index)
207 | }
208 | newPlayer.onAudioEnd = () => {
209 | setIsSpeaking(false)
210 | setCurrentSpeakingIndex(null)
211 | }
212 | audioRef.current = newPlayer
213 | }
214 | } else {
215 | if (audioRef.current && isCurrentSpeakingIndex) {
216 | audioRef.current?.resume()
217 | } else {
218 | clearSpeaking()
219 | const newPlayer = azureSynthesizeSpeech(message)
220 | newPlayer.onAudioStart = () => {
221 | setIsSpeaking(true)
222 | setCurrentSpeakingIndex(index)
223 | }
224 | newPlayer.onAudioEnd = () => {
225 | setIsSpeaking(false)
226 | setCurrentSpeakingIndex(null)
227 | }
228 | audioRef.current = newPlayer
229 | }
230 | setIsSpeaking(true)
231 | }
232 | }
233 |
234 | const toggleSettings = () => {
235 | setIsSettingsOpen(!isSettingsOpen)
236 | }
237 |
238 | const handleTTSToggle = (enabled: boolean) => {
239 | setIsTTSEnabled(enabled)
240 | localStorage.setItem('ttsEnabled', JSON.stringify(enabled))
241 | }
242 |
243 | useEffect(() => {
244 | const storedChats = JSON.parse(localStorage.getItem('chats') || '[]')
245 | setChats(storedChats)
246 |
247 | const storedTTSEnabled = localStorage.getItem('ttsEnabled')
248 | if (storedTTSEnabled !== null) {
249 | setIsTTSEnabled(JSON.parse(storedTTSEnabled))
250 | }
251 |
252 | return () => {
253 | clearSpeaking()
254 | }
255 | }, [])
256 |
257 | // 设置语音识别
258 | useEffect(() => {
259 | recognitionRef.current = azureSpeechToText()
260 |
261 | return () => {
262 | if (recognitionRef.current) {
263 | recognitionRef.current.stopContinuousRecognitionAsync()
264 | }
265 | }
266 | }, [])
267 |
268 | useEffect(() => {
269 | const handleClickOutside = (event: MouseEvent) => {
270 | if (
271 | sidebarRef.current &&
272 | !sidebarRef.current.contains(event.target as Node)
273 | ) {
274 | setIsSidebarOpen(false)
275 | }
276 | }
277 |
278 | document.addEventListener('mousedown', handleClickOutside)
279 | return () => {
280 | document.removeEventListener('mousedown', handleClickOutside)
281 | }
282 | }, [])
283 |
284 | useEffect(() => {
285 | localStorage.setItem('chats', JSON.stringify(chats))
286 | }, [chats])
287 |
288 | return (
289 |
290 | {/* Sidebar */}
291 |
327 |
328 | {/* Main chat area */}
329 |
330 |
331 |
337 | Talk Boost
338 |
341 |
342 |
343 |
344 |
345 |
346 | {messages
347 | .filter((message) => message.role !== 'system')
348 | .map((message, index) => (
349 |
357 | ))}
358 | {error && (
359 |
360 |
361 | {error}
362 |
363 |
364 | )}
365 |
366 |
367 |
368 |
404 |
405 |
406 |
407 |
408 |
409 | {isSettingsOpen && (
410 |
415 | )}
416 |
417 | )
418 | }
419 |
420 | export default TalkBoost
421 |
--------------------------------------------------------------------------------