Init Commit
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
@@ -0,0 +1,24 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
rules:{
|
||||
'no-unused-vars': 'warn',
|
||||
}
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<!-- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>CHAT-JP</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
|
||||
<link rel="alternate icon" href="/favicon.ico"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/>
|
||||
<link rel="manifest" href="/site.webmanifest"/>
|
||||
<meta name="theme-color" content="#2563EB"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [ "src/*" ]
|
||||
},
|
||||
"jsx": "react"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "jp_chat",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"antd": "^6.4.3",
|
||||
"axios": "^1.16.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.",
|
||||
"main": "eslint.config.js",
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 449 B |
|
After Width: | Height: | Size: 798 B |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,45 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="64" y1="32" x2="448" y2="480" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#7C3AED"/>
|
||||
<stop offset="50%" stop-color="#2563EB"/>
|
||||
<stop offset="100%" stop-color="#06B6D4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bubble" x1="132" y1="116" x2="386" y2="374" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#FFFFFF"/>
|
||||
<stop offset="100%" stop-color="#EAF6FF"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="80" y="70" width="360" height="360" filterUnits="userSpaceOnUse">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#0F172A" flood-opacity="0.25"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
|
||||
<g opacity="0.18">
|
||||
<circle cx="96" cy="96" r="8" fill="white"/>
|
||||
<circle cx="416" cy="128" r="6" fill="white"/>
|
||||
<circle cx="408" cy="396" r="9" fill="white"/>
|
||||
<path d="M76 370C132 326 190 328 246 366C300 402 354 390 430 330" stroke="white" stroke-width="18" stroke-linecap="round"/>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<path d="M132 142C132 119.9 149.9 102 172 102H340C362.1 102 380 119.9 380 142V270C380 292.1 362.1 310 340 310H247L178 364C170.4 370 159 364.6 159 354.9V310H172C149.9 310 132 292.1 132 270V142Z" fill="url(#bubble)"/>
|
||||
</g>
|
||||
|
||||
<path d="M204 178L235 252" stroke="#2563EB" stroke-width="18" stroke-linecap="round"/>
|
||||
<path d="M266 178L235 252" stroke="#2563EB" stroke-width="18" stroke-linecap="round"/>
|
||||
<path d="M216 224H254" stroke="#2563EB" stroke-width="16" stroke-linecap="round"/>
|
||||
|
||||
<path d="M301 174H344" stroke="#7C3AED" stroke-width="15" stroke-linecap="round"/>
|
||||
<path d="M322 154V241" stroke="#7C3AED" stroke-width="15" stroke-linecap="round"/>
|
||||
<path d="M292 238C319 225 340 199 350 174" stroke="#7C3AED" stroke-width="15" stroke-linecap="round"/>
|
||||
<path d="M348 238C326 228 309 211 297 190" stroke="#7C3AED" stroke-width="15" stroke-linecap="round"/>
|
||||
|
||||
<circle cx="184" cy="272" r="9" fill="#06B6D4"/>
|
||||
<circle cx="220" cy="272" r="9" fill="#06B6D4"/>
|
||||
<circle cx="256" cy="272" r="9" fill="#06B6D4"/>
|
||||
|
||||
<path d="M401 171L408 190L427 197L408 204L401 223L394 204L375 197L394 190L401 171Z" fill="white" opacity="0.95"/>
|
||||
<path d="M101 251L106 264L119 269L106 274L101 287L96 274L83 269L96 264L101 251Z" fill="white" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 449 B |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 798 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "AI Language Chat",
|
||||
"short_name": "AI Lang",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#2563EB",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {GContext} from '@/utils/g_context.js'
|
||||
import {App as AntdApp, ConfigProvider, theme} from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
|
||||
const THEME_STORAGE_KEY = 'antd_theme_mode'
|
||||
|
||||
function getInitialThemeMode() {
|
||||
if (typeof window !== 'undefined')
|
||||
return 'dark'
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (savedTheme === 'light' || savedTheme === 'dark')
|
||||
return savedTheme
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function createAntdThemeConfig(themeMode) {
|
||||
const isDark = themeMode === 'dark'
|
||||
return {
|
||||
// 配置要点:algorithm 决定整套 Design Token 的生成算法。
|
||||
// defaultAlgorithm 是亮色主题,darkAlgorithm 是暗色主题;切换这里即可全局换肤。
|
||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
|
||||
// 配置要点:token 是全局设计变量,适合放品牌色、圆角、字体、基础间距等。
|
||||
// 组件会消费这些变量,因此不要在业务组件里到处写零散颜色。
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
colorSuccess: '#2fb344',
|
||||
colorWarning: '#faad14',
|
||||
colorError: '#ff4d4f',
|
||||
borderRadius: 8,
|
||||
fontFamily: `-apple-system, BlinkMacSystemFont,Noto Sans SC,Noto Sans JP,\
|
||||
"Segoe UI", Roboto, "Microsoft YaHei", sans-serif`,
|
||||
colorBgLayout: isDark ? '#101418' : '#f5f7fb',
|
||||
},
|
||||
// 配置要点:components 用于覆盖单个组件的默认 Token。
|
||||
// 当某类组件需要统一风格时,优先在这里集中配置,而不是给每个组件写 className。
|
||||
components: {
|
||||
Button: {
|
||||
borderRadius: 8,
|
||||
controlHeight: 36,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 8,
|
||||
headerBg: isDark ? '#151a20' : '#ffffff',
|
||||
},
|
||||
Layout: {
|
||||
bodyBg: isDark ? '#101418' : '#f5f7fb',
|
||||
headerBg: isDark ? '#151a20' : '#ffffff',
|
||||
siderBg: isDark ? '#151a20' : '#ffffff',
|
||||
},
|
||||
Menu: {
|
||||
itemBorderRadius: 8,
|
||||
},
|
||||
},
|
||||
// 配置要点:开启 CSS 变量后,可以在自定义 CSS 中使用 Antd Token。
|
||||
// key 跟随主题变化,避免亮暗主题切换时复用旧变量。
|
||||
cssVar: {
|
||||
prefix: 'antd-app',
|
||||
key: themeMode,
|
||||
},
|
||||
hashed: true
|
||||
}
|
||||
}
|
||||
|
||||
export default function AntdBootstrap({children}) {
|
||||
const [themeMode, setThemeMode] = useState(getInitialThemeMode())
|
||||
const antdTheme = useMemo(() => createAntdThemeConfig(themeMode), [themeMode])
|
||||
const isDark = themeMode === 'dark'
|
||||
const ctxValue = useMemo(() => ({
|
||||
isDark,
|
||||
themeMode,
|
||||
setThemeMode
|
||||
}), [isDark, themeMode])
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = themeMode
|
||||
document.documentElement.style.colorScheme = themeMode
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, themeMode)
|
||||
}, [themeMode])
|
||||
|
||||
return (
|
||||
<GContext.Provider value={ctxValue}>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
componentSize={"middle"}
|
||||
form={{
|
||||
requiredMark: 'optional',
|
||||
scrollToFirstError: true,
|
||||
validateMessages: {
|
||||
required: "${label} can't be null",
|
||||
types: {
|
||||
email: "${label} can't be empty"
|
||||
}
|
||||
}
|
||||
}}
|
||||
input={{allowClear: true, autoComplete: 'off'}}
|
||||
inputNumber={{variant: 'outlined'}}
|
||||
select={{allowClear: true, showSearch: true}}
|
||||
button={{autoInsertSpace: true}}
|
||||
theme={antdTheme}
|
||||
getPopupContainer={(triggerNode) => triggerNode?.parentElement ?? document.body}
|
||||
> {/* end of ConfigProvider */}
|
||||
<AntdApp className="antd-app">
|
||||
{children}
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
</GContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {BrowserRouter as Router, Routes, Route, Navigate} from 'react-router-dom'
|
||||
|
||||
import Home from './pages/Home.jsx';
|
||||
import Test from './pages/test/Test.jsx';
|
||||
import Login from '@/pages/login/Login.jsx'
|
||||
import {RequireAuth} from '@/pages/RequireAuth.jsx'
|
||||
|
||||
function App() {
|
||||
return (<Router><Routes>
|
||||
<Route path="/login" element={<Login/>}/>
|
||||
|
||||
{/*以下是被保护的路由*/}
|
||||
<Route element={<RequireAuth/>}>
|
||||
<Route path="/" element={<Home/>}/>
|
||||
<Route path="/test" element={<Test/>}/>
|
||||
</Route>
|
||||
{/*以上是被保护的路由*/}
|
||||
|
||||
{/* 兜底:未匹配到且已登录,则 404;未登录,则跳 login */}
|
||||
<Route path="*" element={(<div>404 Not Found</div>)}/>
|
||||
</Routes> </Router>)
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
const BASE_URL = 'https://api.rop.cc/jpchat'
|
||||
const URL_GETURL = 'https://api.rop.cc/jpchat/geturl'
|
||||
|
||||
/**
|
||||
* 失败返回 JSON
|
||||
* {
|
||||
* ok:false,
|
||||
* threadid?:
|
||||
* usertext?:
|
||||
* message:
|
||||
* }
|
||||
* 成功返回 multipart/form-data
|
||||
* Content-Type: multipart/form-data; boundary=----WEBKIT_FORM_BOUNDARY7MA4YWxkTrZu0gW
|
||||
* body:
|
||||
* --boundary
|
||||
* Content-Disposition: form-data; name="meta"
|
||||
* Content-Type: application/json; charset=utf-8
|
||||
* Content-Disposition: form-data; name="audio"
|
||||
* Content-Type: audio/wav
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
export async function request(audioFile, threadid = null) {
|
||||
if (!audioFile) return;
|
||||
console.warn("api::request,threadid=",threadid)
|
||||
const formData = new FormData();
|
||||
formData.append('model', 'gpt-5.5')
|
||||
formData.append('modelReasoningEffort', 'medium')
|
||||
formData.append('audio', audioFile)
|
||||
if (threadid) formData.append('threadid', threadid)
|
||||
const res = await fetch(URL_GETURL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
withCredentials: true,
|
||||
credentials: 'include'
|
||||
// 不要手动设置 Content-Type
|
||||
});
|
||||
const contentType = res.headers.get('content-type');
|
||||
|
||||
if(res.status === 401) {
|
||||
console.log("认证失败 res =", res)
|
||||
return {ok:false,message:'auth failed'}
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
let data = await res.json();
|
||||
console.log("返回json,失败了 message =", data.message)
|
||||
return data;
|
||||
}
|
||||
|
||||
if (contentType.includes('multipart/form-data') && res.ok) {
|
||||
let formdata = await res.formData();
|
||||
const dataText = formdata.get('meta');
|
||||
const audiofile = formdata.get('audio');
|
||||
|
||||
const data = JSON.parse(dataText);
|
||||
|
||||
// console.log('data =', data);
|
||||
// console.log('audiofile =', audiofile);
|
||||
|
||||
return {
|
||||
ok: data.ok,
|
||||
url: data.url,
|
||||
threadid:data.threadid,
|
||||
usertext: data.usertext,
|
||||
replytext: data.replytext,
|
||||
blob: audiofile
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {ok: false, message: '未知错误'}
|
||||
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,43 @@
|
||||
.composer {
|
||||
height: 200px;
|
||||
background-color: #333;
|
||||
width: 95%;
|
||||
margin-bottom: 2%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.composer-button {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
/* iOS Safari 长按菜单 */
|
||||
-webkit-touch-callout: none;
|
||||
|
||||
/* 减少移动端浏览器默认手势干扰 */
|
||||
touch-action: none;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
height: 70px;
|
||||
width: 47.15%;
|
||||
|
||||
/* 避免 iOS 点按高亮 */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.composer-button * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.pad-bottom {
|
||||
height: 20px;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {useRef, forwardRef, useImperativeHandle, useState, useCallback} from 'react'
|
||||
import {Button, App} from 'antd'
|
||||
|
||||
import './Composer.css'
|
||||
|
||||
export default forwardRef(function Composer(props, ref) {
|
||||
const ref_StateLabel = useRef(null)
|
||||
const [btnTxt, setBtnTxt] = useState('SPEAK');
|
||||
const [debug, setDebug] = useState('debug')
|
||||
const [stateTxt, setStateTxt] = useState('state')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function hOnRcStateChanged(state) {
|
||||
// ref_StateLabel.current.textContent = state
|
||||
setStateTxt(state)
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
hOnRcStateChanged,
|
||||
}))
|
||||
|
||||
async function hPointerDown(e) {
|
||||
// setDebug('pointerDown')
|
||||
// 只处理鼠标左键 / 触摸 / 笔
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
e.preventDefault()
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId)
|
||||
|
||||
// 捕获指针,避免手指移出按钮后收不到 pointerup
|
||||
setBtnTxt("RELEASE TO STOP")
|
||||
|
||||
const _ = props.callbacks.onPress()
|
||||
}
|
||||
|
||||
const hPointerCancel = (e) => {
|
||||
|
||||
setDebug('pointer cancelled!')
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
async function hPointerUp(e) {
|
||||
// setDebug('pointer up!')
|
||||
e.preventDefault();
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
await props.callbacks.unlockAudio()
|
||||
|
||||
setBtnTxt("SPEAK")
|
||||
|
||||
|
||||
const _ = props.callbacks.onUnPress()
|
||||
}
|
||||
|
||||
function hContextMenu(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const hLostPointerCapture = useCallback((e) => {
|
||||
setDebug('pointer Lost Capture!')
|
||||
|
||||
e.preventDefault();
|
||||
// stopPress(e);
|
||||
}, []);
|
||||
// }, [stopPress]);
|
||||
|
||||
return (<div className="composer">
|
||||
===composer===
|
||||
{/*<div>{debug}</div>*/}
|
||||
<div ref={ref_StateLabel}>{stateTxt}</div>
|
||||
<br/>
|
||||
<Button className="composer-button"
|
||||
onPointerDown={hPointerDown}
|
||||
onPointerUp={hPointerUp}
|
||||
onContextMenu={hContextMenu}
|
||||
onPointerCancel={hPointerCancel}
|
||||
onLostPointerCapture={hLostPointerCapture}
|
||||
>
|
||||
{btnTxt}
|
||||
</Button>
|
||||
<br/>
|
||||
<div className="pad-bottom"></div>
|
||||
</div>)
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
.cover{
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.66);
|
||||
z-index: 1000;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*.threads-content{*/
|
||||
/* display: flex;*/
|
||||
/* width: 80%;*/
|
||||
/* max-width: 500px;*/
|
||||
/* height: 100%;*/
|
||||
/* justify-content: center;*/
|
||||
/* align-items: center;*/
|
||||
/* flex-direction: column;*/
|
||||
/* gap: 30px;*/
|
||||
/*}*/
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import {Alert, Flex, Spin} from 'antd'
|
||||
import './Cover.css'
|
||||
|
||||
const contentStyle = {
|
||||
padding: 50,
|
||||
background: 'rgba(0, 0, 0, 0.55)',
|
||||
borderRadius: 4,
|
||||
};
|
||||
|
||||
const content = <div style={contentStyle}/>;
|
||||
|
||||
export function Cover() {
|
||||
|
||||
return (<div className={"cover"}>
|
||||
<Spin description="Loading" size="large">
|
||||
{content}
|
||||
</Spin>
|
||||
</div>)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.long-press-btn {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
/* iOS Safari 长按菜单 */
|
||||
-webkit-touch-callout: none;
|
||||
|
||||
/* 减少移动端浏览器默认手势干扰 */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.long-press-btn * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {useRef} from 'react';
|
||||
import {Button} from 'antd';
|
||||
import './LongpressedButton.css'
|
||||
|
||||
export function LongpressedButton(props) {
|
||||
const timerRef = useRef(null);
|
||||
const pressingRef = useRef(false);
|
||||
const longPressedRef = useRef(false);
|
||||
|
||||
function handlePointerDown(e) {
|
||||
// 只处理左键 / 手指 / 触控笔
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
|
||||
// 防止手机长按时选中文本、弹出系统菜单
|
||||
e.preventDefault();
|
||||
|
||||
pressingRef.current = true;
|
||||
longPressedRef.current = false;
|
||||
|
||||
// 捕获指针,避免手指移出按钮后收不到 pointerup
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (!pressingRef.current) return;
|
||||
|
||||
longPressedRef.current = true;
|
||||
|
||||
console.log('长按触发');
|
||||
// 在这里写长按逻辑
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function handlePointerUp(e) {
|
||||
clearTimeout(timerRef.current);
|
||||
|
||||
if (!pressingRef.current) return;
|
||||
|
||||
pressingRef.current = false;
|
||||
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
|
||||
console.log('抬起触发');
|
||||
|
||||
if (longPressedRef.current) {
|
||||
console.log('长按后的抬起');
|
||||
// 长按完成后松手
|
||||
} else {
|
||||
console.log('普通点击式抬起');
|
||||
// 没达到长按时间就松手
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerCancel() {
|
||||
clearTimeout(timerRef.current);
|
||||
pressingRef.current = false;
|
||||
longPressedRef.current = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
className="long-press-btn"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onPointerLeave={handlePointerCancel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
draggable={false}
|
||||
>
|
||||
{props.children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
.msgcard {
|
||||
display: flex;
|
||||
width: 97%;
|
||||
height: auto;
|
||||
background: #455;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 16px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
}
|
||||
|
||||
.msgcard-wrap-assistant{
|
||||
width: 100%;
|
||||
display: flex;
|
||||
min-height: 75px;
|
||||
flex-direction: row;
|
||||
|
||||
padding: 8px 10px 10px 10px; /* 底部 10px */
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.msgcard-wrap-user{
|
||||
width: 100%;
|
||||
display: flex;
|
||||
min-height: 75px;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
padding: 8px 10px 10px 10px; /* 底部 10px */
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
background-color: #122;
|
||||
flex-shrink: 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.msg-avatar-btn {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.msg-text {
|
||||
width: calc((100% - 80px) * 0.95);
|
||||
left: 80px;
|
||||
top: 8px;
|
||||
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {Button} from 'antd'
|
||||
import {PlayCircleOutlined} from '@ant-design/icons'
|
||||
import './MessageCard.css'
|
||||
import * as player from '@/utils/player.js'
|
||||
import React from "react";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export default function MessageCard({message}) {
|
||||
if (!message) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
let bAssistant = false;
|
||||
let cn_wrap = 'msgcard-wrap-user'
|
||||
if (message.role && message.role === 'assistant') {
|
||||
bAssistant = true;
|
||||
cn_wrap = 'msgcard-wrap-assistant'
|
||||
}
|
||||
|
||||
async function playAudio() {
|
||||
await player.play(message.messageid)
|
||||
}
|
||||
|
||||
const reply = React.useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(message.content)
|
||||
} catch (e) {
|
||||
return {text: "invalid text"}
|
||||
}
|
||||
}, [])
|
||||
const guide = React.useMemo(() => {
|
||||
if (reply.guide != null) {
|
||||
return (<><br/>----------<br/>Gramma Guide: {reply.guide}</>)
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, [])
|
||||
return (<div className="msgcard">
|
||||
<div className={cn_wrap}>
|
||||
<div className={"msg-avatar"}>
|
||||
{bAssistant ?
|
||||
<Button
|
||||
type="default"
|
||||
className="msg-avatar-btn"
|
||||
icon={<PlayCircleOutlined/>}
|
||||
size="large"
|
||||
block
|
||||
onClick={playAudio}
|
||||
/> :
|
||||
<div>USER</div>}
|
||||
</div>
|
||||
<div className={"msg-text"}>
|
||||
{reply.text ? reply.text : 'no text'}<br/>
|
||||
{guide && guide}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
|
||||
export default function PrivateRoute() {
|
||||
// const token = localStorage.getItem('jwt');
|
||||
////// 如果没有 token,则跳转到 /login
|
||||
// if (!token) {
|
||||
// return <Navigate to="/login" replace />;
|
||||
// }
|
||||
// 已登录:渲染后续子路由
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
:root {
|
||||
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Noto Sans SC','Noto Sans JP',
|
||||
'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #232323;
|
||||
color: aliceblue;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
/*touch-action: manipulation;*/
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
[role="button"] {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* 当用户没有开启“减少动态效果”偏好时,里面的样式才生效*/
|
||||
/* no-preference : 不介意动画(有动画) */
|
||||
/* reduce : 减少动画 */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.logo {
|
||||
animation: spin 20s linear infinite;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {StrictMode} from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import AntdBootstrap from './AntdBootstrap.jsx'
|
||||
import '@/index.css';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<AntdBootstrap>
|
||||
<App/>
|
||||
</AntdBootstrap>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-header{
|
||||
display: grid;
|
||||
grid-template-columns:1fr auto 1fr;
|
||||
height: 50px;
|
||||
background: #202020;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-title{
|
||||
align-self: center;
|
||||
white-space: nowrap; /* 不换行 */
|
||||
overflow: hidden; /* 超出隐藏 */
|
||||
}
|
||||
|
||||
.btn-frame {
|
||||
justify-self: end;
|
||||
align-self: center;
|
||||
margin-right: 10px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.home-content {
|
||||
height: calc(100vh - 60px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
width: 650px;
|
||||
background-color: #444444;
|
||||
overflow-y: auto;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.arr-container{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding-top:10px;
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.home-content {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import React, {useState} from 'react'
|
||||
import {useNavigate, useLocation} from 'react-router-dom'
|
||||
import {Button, Flex} from 'antd'
|
||||
import './Home.css'
|
||||
import Composer from '@/components/Composer.jsx'
|
||||
import MessageCard from '@/components/MessageCard.jsx'
|
||||
import {Recorder} from '@/utils/recorder'
|
||||
import * as apis from '@/api.jsx'
|
||||
import * as saving from '@/utils/saving.jsx'
|
||||
import {Option} from '@/pages/option/Option.jsx'
|
||||
import {Threads} from '@/pages/threads/Threads.jsx'
|
||||
import {CommentOutlined, SettingOutlined} from "@ant-design/icons";
|
||||
import {Cover} from "@/components/Cover.jsx";
|
||||
import {AutoAudioPlayer} from '@/utils/auto_audio_player.js'
|
||||
|
||||
let recorder
|
||||
let cur_thread = null
|
||||
|
||||
async function saveMessage(threadid, role, content, audioblob = null) {
|
||||
const message = {
|
||||
messageid: crypto.randomUUID(),
|
||||
role: role,
|
||||
content: content,
|
||||
hasaudio: !!audioblob
|
||||
}
|
||||
await saving.addMessage(threadid, message, audioblob)
|
||||
}
|
||||
|
||||
function saveBlobToLocal(blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'test_audio.webm'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [debug, setDebug] = useState('debug');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [showCover, setShowCover] = React.useState(false);
|
||||
|
||||
//#region ---- 对话内容区 ----
|
||||
/** thread
|
||||
* thrdeadid: xxxxx,
|
||||
* messages: [{
|
||||
* messageid:
|
||||
* role:
|
||||
* content:
|
||||
* hasaudio:
|
||||
* }, ...
|
||||
* ]
|
||||
*/
|
||||
|
||||
let selected_thread_name = saving.getSelectedThread();
|
||||
if (saving.checkThreadid(selected_thread_name)) {
|
||||
cur_thread = saving.getThread(selected_thread_name)
|
||||
} else {
|
||||
selected_thread_name = 'New Thread'
|
||||
cur_thread = {threadid: "New Thread", messages: []}
|
||||
}
|
||||
|
||||
const [tid, setTid] = React.useState(selected_thread_name)
|
||||
|
||||
const [refresh, setRefresh] = React.useState(0)
|
||||
const bottomRef = React.useRef(null)
|
||||
React.useEffect(() => {
|
||||
bottomRef.current &&
|
||||
bottomRef.current.scrollIntoView({behavior: 'smooth', block: 'end'})
|
||||
}, [cur_thread.messages.length])
|
||||
|
||||
// console.log("[[cur_thread]]\n", cur_thread)
|
||||
|
||||
//#endregion
|
||||
|
||||
|
||||
//#region ---- composer ----
|
||||
const ref_composer = React.useRef(null)
|
||||
|
||||
const autoAudioRef = React.useRef(null);
|
||||
|
||||
if (!autoAudioRef.current) {
|
||||
autoAudioRef.current = new AutoAudioPlayer();
|
||||
}
|
||||
|
||||
|
||||
function hRecorderStateChange(nxtState) {
|
||||
ref_composer.current?.hOnRcStateChanged(nxtState)
|
||||
}
|
||||
|
||||
React.useMemo(() => {
|
||||
recorder = new Recorder({
|
||||
autoPlay: true,
|
||||
onStateChange: hRecorderStateChange,
|
||||
})
|
||||
return () => { recorder.close() }
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
async function onPress() {
|
||||
try {
|
||||
// 开始录音
|
||||
await recorder.startRecorder()
|
||||
} catch (e) {
|
||||
setDebug(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnPress() {
|
||||
try {
|
||||
setShowCover(true)
|
||||
const blob = await recorder.stopRecording()
|
||||
if (!blob) return
|
||||
|
||||
// saveBlobToLocal(blob)
|
||||
const _tid = tid === 'New Thread' ? null : tid
|
||||
const result = await apis.request(blob, _tid)
|
||||
|
||||
console.log('[[HOME::result]]\n', result)
|
||||
if (!result.ok && result.message && result.message === 'auth failed') {
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: {
|
||||
from: location.pathname,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/** result : JSON
|
||||
* ok: true|false,
|
||||
* url: xxxxxxx,
|
||||
* threadid: xxxxxxx,
|
||||
* usertext: xxxxxx,
|
||||
* replytext: xxxxxx,
|
||||
* blob: audioFile,
|
||||
* message?: failure message
|
||||
*/
|
||||
if (result.ok) {
|
||||
const usertxt = JSON.stringify({text: result.usertext})
|
||||
await saveMessage(result.threadid, 'user', usertxt)
|
||||
await saveMessage(result.threadid, 'assistant', result.replytext, result.blob)
|
||||
if (cur_thread.threadid === 'New Thread') {
|
||||
saving.setSelectedThread(result.threadid)
|
||||
setTid(result.threadid)
|
||||
}
|
||||
setShowCover(false)
|
||||
|
||||
try {
|
||||
autoAudioRef.current.setVolume(1.5);
|
||||
await autoAudioRef.current.playBlob(result.blob);
|
||||
} catch (e) {
|
||||
setDebug(`${e.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.warn("[[failed]]\n", result.message)
|
||||
setShowCover(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setShowCover(false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function unlockAudio() {
|
||||
// await audioPlayerRef.current.unlock();
|
||||
try {
|
||||
await autoAudioRef.current.unlockAndKeepAlive();
|
||||
} catch (err) {
|
||||
console.error('unlock audio failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const cb_compposer = {
|
||||
onPress: onPress,
|
||||
onUnPress: onUnPress,
|
||||
unlockAudio: unlockAudio
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ---- 设置 ----
|
||||
const [showOption, setShowOptions] = React.useState(false)
|
||||
|
||||
async function onOptionClicked() {
|
||||
setShowOptions(!showOption)
|
||||
}
|
||||
|
||||
async function closeOptionPanel() {
|
||||
setShowOptions(false)
|
||||
}
|
||||
|
||||
const cb_option = {
|
||||
closeOptionPanel: closeOptionPanel
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
||||
|
||||
|
||||
//#region ---- 选择对话面板 ----
|
||||
|
||||
const [showThreads, setShowThreads] = React.useState(false)
|
||||
|
||||
async function onThreadsClicked() {
|
||||
setShowThreads(!showThreads)
|
||||
}
|
||||
|
||||
async function closeThreadsPanel() {
|
||||
setShowThreads(false)
|
||||
}
|
||||
|
||||
async function hSelectThread(selectedid) {
|
||||
console.log("HOME::SELECT_THREAD", selectedid)
|
||||
saving.setSelectedThread(selectedid)
|
||||
setTid(selectedid)
|
||||
await closeThreadsPanel()
|
||||
}
|
||||
|
||||
async function hNewThread() {
|
||||
saving.setSelectedThread("New Thread")
|
||||
setTid("New Thread")
|
||||
await closeThreadsPanel()
|
||||
}
|
||||
|
||||
async function hDeleteThread(key) {
|
||||
saving.deleteThread(key)
|
||||
if (cur_thread.threadid === key) {
|
||||
await hSelectThread("New Thread")
|
||||
}
|
||||
setShowThreads(false)
|
||||
}
|
||||
|
||||
const cb_threads = {
|
||||
closeThreadsPanel: closeThreadsPanel,
|
||||
hSelectThread: hSelectThread,
|
||||
hNewThread: hNewThread,
|
||||
hDeleteThread: hDeleteThread,
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
|
||||
|
||||
return ( // -------- //
|
||||
<div className="home">
|
||||
<div>
|
||||
<div className="home-header">
|
||||
<div></div>
|
||||
<div className="home-title">{tid}</div>
|
||||
<Flex className="btn-frame">
|
||||
<Button
|
||||
className="btn-comment"
|
||||
type="default"
|
||||
icon={<CommentOutlined/>}
|
||||
onClick={onThreadsClicked}
|
||||
size={"large"}
|
||||
/>
|
||||
<Button
|
||||
className="btn-option"
|
||||
type="default"
|
||||
icon={<SettingOutlined refreshtoken={refresh}/>}
|
||||
size={"large"}
|
||||
onClick={onOptionClicked}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
{/*{debug}*/}
|
||||
<div className="home-content">
|
||||
<div className="arr-container">
|
||||
{cur_thread.messages.map((item, idx) => (<MessageCard key={idx} message={item}/>))}
|
||||
<div ref={bottomRef}/>
|
||||
</div>
|
||||
<Composer ref={ref_composer} callbacks={cb_compposer}/>
|
||||
</div>
|
||||
</div>
|
||||
{showOption && <Option callbacks={cb_option}/>}
|
||||
{showThreads && <Threads threads={saving.listupthreads()} callbacks={cb_threads}/>}
|
||||
{showCover && <Cover/>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
Outlet,
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
|
||||
|
||||
export function RequireAuth() {
|
||||
const [status, setStatus] = React.useState('checking');
|
||||
const location = useLocation();
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch('https://api.rop.cc/auth/check', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (res.ok) {
|
||||
console.log("[[RequireAuth]] Cookie有效")
|
||||
setStatus('authed');
|
||||
} else {
|
||||
console.log("[[RequireAuth]] Cookie无效")
|
||||
setStatus('guest');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setStatus('guest');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _ = checkAuth();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (status === 'checking') {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (status === 'guest') {
|
||||
return (<Navigate
|
||||
to="/login"
|
||||
replace
|
||||
state={{from: location.pathname}}
|
||||
/>);
|
||||
}
|
||||
|
||||
return <Outlet/>;
|
||||
}
|
||||
|
||||
// function App() {
|
||||
// return (
|
||||
// <Router>
|
||||
// <Routes>
|
||||
// {/* 公共路由 */}
|
||||
// <Route path="/login" element={<Login />} />
|
||||
//
|
||||
// {/* 以下是被保护的路由 */}
|
||||
// <Route element={<RequireAuth />}>
|
||||
// <Route path="/" element={<Home />} />
|
||||
// <Route path="/test" element={<Test />} />
|
||||
//
|
||||
// {/* 已登录但路由不存在,才显示 404 */}
|
||||
// <Route path="*" element={<div>404 Not Found</div>} />
|
||||
// </Route>
|
||||
// </Routes>
|
||||
// </Router>
|
||||
// );
|
||||
// }
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
|
||||
.login {
|
||||
justify-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 300px;
|
||||
margin: 100px;
|
||||
gap: 20px;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
|
||||
import './Login.css';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const resp = await axios.post(
|
||||
'https://api.rop.cc/auth',
|
||||
{username, password},
|
||||
{withCredentials: true}
|
||||
);
|
||||
console.log('接收到resp:', resp);
|
||||
// 假设后端返回 { token: 'xxxx' } 保留本地token
|
||||
// localStorage.setItem('jwt', resp.data.token);
|
||||
// localStorage.setItem('_uname', resp.data.username);
|
||||
// 跳转到首页或登录前尝试访问的页面
|
||||
navigate('/', {replace: true});
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
alert(`[[Login failed]] error = ${err}`);
|
||||
}
|
||||
};
|
||||
return (<form className="login" onSubmit={handleSubmit}>
|
||||
<h2>Login</h2>
|
||||
<input
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="username"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="password"
|
||||
/>
|
||||
<button type="submit">submit</button>
|
||||
</form>);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.option{
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.66);
|
||||
z-index: 1000;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.option-content{
|
||||
display: flex;
|
||||
width: 80%;
|
||||
max-width: 500px;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 1000;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.floating-panel {
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import {Button} from 'antd'
|
||||
import './Option.css'
|
||||
|
||||
export function Option(props) {
|
||||
|
||||
|
||||
|
||||
return (<div className="option">
|
||||
<div className="option-content">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={props.callbacks.closeOptionPanel}
|
||||
size="large"
|
||||
block
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={props.callbacks.closeOptionPanel}
|
||||
size="large"
|
||||
block
|
||||
>
|
||||
Close Option
|
||||
</Button>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import './Test.css'
|
||||
|
||||
|
||||
export default function Test(){
|
||||
const [hash, setHash] = useState(() => window.location.hash);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
setHash(window.location.hash);
|
||||
};
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", handleHashChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showOptionPanel = hash === "#option";
|
||||
|
||||
function openOptionPanel() {
|
||||
window.location.hash = "option";
|
||||
}
|
||||
|
||||
function closeOptionPanel() {
|
||||
// 去掉 #option,但不刷新页面
|
||||
window.history.pushState(
|
||||
"",
|
||||
document.title,
|
||||
window.location.pathname + window.location.search
|
||||
);
|
||||
|
||||
// pushState 不会自动触发 hashchange,所以这里手动同步状态
|
||||
setHash("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={openOptionPanel}>打开设置面板</button>
|
||||
|
||||
{showOptionPanel && (
|
||||
<div className="panel">
|
||||
<h2>设置面板</h2>
|
||||
<button onClick={closeOptionPanel}>关闭</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.threads {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.66);
|
||||
z-index: 1000;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.threads-content {
|
||||
display: flex;
|
||||
width: 80%;
|
||||
max-width: 500px;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.theads-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
/*background-color: red;*/
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import {Button} from 'antd'
|
||||
import {DeleteOutlined} from '@ant-design/icons'
|
||||
import './Threads.css'
|
||||
|
||||
|
||||
|
||||
export function Threads(props) {
|
||||
|
||||
const ids = props.threads.map((item) => {return item.threadid})
|
||||
|
||||
async function hClicked(key) {
|
||||
await props.callbacks.hSelectThread(key)
|
||||
}
|
||||
|
||||
async function hClickDelete(key) {
|
||||
await props.callbacks.hDeleteThread(key)
|
||||
}
|
||||
|
||||
async function hNewThread() {
|
||||
await props.callbacks.hNewThread()
|
||||
}
|
||||
|
||||
return (<div className="threads">
|
||||
<div className="threads-content">
|
||||
<Button
|
||||
type="default"
|
||||
block
|
||||
size="large"
|
||||
onClick={hNewThread}
|
||||
>
|
||||
New Thread
|
||||
</Button>
|
||||
|
||||
{ids.map(id => (
|
||||
<div className={"theads-row"} key={id}>
|
||||
<Button
|
||||
type="default"
|
||||
block
|
||||
size="large"
|
||||
onClick={() => hClicked(id)}
|
||||
> {id}</Button>
|
||||
<Button
|
||||
type="default"
|
||||
style={{width:"70px"}}
|
||||
size="large"
|
||||
icon={<DeleteOutlined/>}
|
||||
onClick={() => hClickDelete(id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="default"
|
||||
onClick={props.callbacks.closeThreadsPanel}
|
||||
size="large"
|
||||
block
|
||||
>
|
||||
Close Panel
|
||||
</Button>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
const DB_NAME = 'audio-db';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'audios';
|
||||
|
||||
// 打开数据库
|
||||
export function openAudioDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
// 第一次创建数据库,或者版本升级时执行
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveAudio(id,blob, extra = {}) {
|
||||
const db = await openAudioDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
store.put({
|
||||
id,
|
||||
blob,
|
||||
type: blob.type,
|
||||
savedAt: Date.now(),
|
||||
...extra
|
||||
});
|
||||
|
||||
tx.oncomplete = () => {
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
tx.onerror = () => {
|
||||
reject(tx.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export async function getAudio(id) {
|
||||
const db = await openAudioDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
const request = store.get(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result || null);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export async function deleteAudio(id) {
|
||||
const db = await openAudioDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
store.delete(id);
|
||||
|
||||
tx.oncomplete = () => {
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
tx.onerror = () => {
|
||||
reject(tx.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function getAllAudios() {
|
||||
const db = await openAudioDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
export class AutoAudioPlayer {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
|
||||
this.keepAliveSource = null;
|
||||
this.keepAliveGain = null;
|
||||
|
||||
this.playGain = null;
|
||||
this.currentSource = null;
|
||||
|
||||
this.volume = 1.0;
|
||||
}
|
||||
|
||||
getContext() {
|
||||
if (!this.ctx) {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
this.ctx = new AudioContext();
|
||||
}
|
||||
|
||||
if (!this.playGain) {
|
||||
this.playGain = this.ctx.createGain();
|
||||
this.playGain.gain.value = this.volume;
|
||||
this.playGain.connect(this.ctx.destination);
|
||||
}
|
||||
|
||||
return this.ctx;
|
||||
}
|
||||
|
||||
setVolume(volume) {
|
||||
this.volume = volume;
|
||||
|
||||
if (this.playGain && this.ctx) {
|
||||
this.playGain.gain.setValueAtTime(volume, this.ctx.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
async unlockAndKeepAlive() {
|
||||
const ctx = this.getContext();
|
||||
if (ctx.state === 'suspended') {
|
||||
await ctx.resume();
|
||||
}
|
||||
if (this.keepAliveSource) return;
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.frequency.value = 20;
|
||||
|
||||
// 只给保活用,不能拿来播放真实音频
|
||||
gain.gain.value = 0.00001;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start();
|
||||
|
||||
this.keepAliveSource = osc;
|
||||
this.keepAliveGain = gain;
|
||||
}
|
||||
|
||||
async playBlob(blob) {
|
||||
const ctx = this.getContext();
|
||||
|
||||
if (ctx.state === 'suspended') {
|
||||
await ctx.resume();
|
||||
}
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
|
||||
|
||||
if (this.currentSource) {
|
||||
try {
|
||||
this.currentSource.stop();
|
||||
} catch (_) {}
|
||||
this.currentSource = null;
|
||||
}
|
||||
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
|
||||
// 真实音频接到 playGain,不要接 keepAliveGain
|
||||
source.connect(this.playGain);
|
||||
|
||||
this.currentSource = source;
|
||||
|
||||
source.start(0);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
source.onended = () => {
|
||||
if (this.currentSource === source) {
|
||||
this.currentSource = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
stopKeepAlive() {
|
||||
if (this.keepAliveSource) {
|
||||
try {
|
||||
this.keepAliveSource.stop();
|
||||
} catch (_) {}
|
||||
|
||||
this.keepAliveSource = null;
|
||||
this.keepAliveGain = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// export class AutoAudioPlayer {
|
||||
// constructor() {
|
||||
// this.ctx = null;
|
||||
// this.keepAliveSource = null;
|
||||
// this.keepAliveGain = null;
|
||||
// this.currentSource = null;
|
||||
// this.unlocked = false;
|
||||
// }
|
||||
//
|
||||
// getContext() {
|
||||
// if (!this.ctx) {
|
||||
// const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
// this.ctx = new AudioContext();
|
||||
// }
|
||||
// return this.ctx;
|
||||
// }
|
||||
//
|
||||
// async unlockAndKeepAlive() {
|
||||
// const ctx = this.getContext();
|
||||
//
|
||||
// if (ctx.state === 'suspended') {
|
||||
// await ctx.resume();
|
||||
// }
|
||||
//
|
||||
// if (this.keepAliveSource) {
|
||||
// this.unlocked = true;
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // 不能用完全一次性静音 buffer。
|
||||
// // 用 oscillator 持续占住音频输出链路。
|
||||
// const osc = ctx.createOscillator();
|
||||
// const gain = ctx.createGain();
|
||||
//
|
||||
// osc.frequency.value = 20;
|
||||
//
|
||||
// // 不建议设成 0;有些浏览器会优化掉静音链路。
|
||||
// // 0.00001 基本听不到,但可以让 graph 持续处于活动状态。
|
||||
// gain.gain.value = 0.00001;
|
||||
//
|
||||
// osc.connect(gain);
|
||||
// gain.connect(ctx.destination);
|
||||
//
|
||||
// osc.start();
|
||||
//
|
||||
// this.keepAliveSource = osc;
|
||||
// this.keepAliveGain = gain;
|
||||
// this.unlocked = true;
|
||||
// }
|
||||
//
|
||||
// async playBlob(blob) {
|
||||
// const ctx = this.getContext();
|
||||
//
|
||||
// if (ctx.state === 'suspended') {
|
||||
// await ctx.resume();
|
||||
// }
|
||||
//
|
||||
// const arrayBuffer = await blob.arrayBuffer();
|
||||
//
|
||||
// // Safari 对同一个 ArrayBuffer decode 后可能会 detach,保险起见 slice 一份
|
||||
// const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
|
||||
//
|
||||
// if (this.currentSource) {
|
||||
// try {
|
||||
// this.currentSource.stop();
|
||||
// } catch (_) {}
|
||||
// this.currentSource = null;
|
||||
// }
|
||||
//
|
||||
// const source = ctx.createBufferSource();
|
||||
// source.buffer = audioBuffer;
|
||||
// source.connect(ctx.destination);
|
||||
//
|
||||
// this.currentSource = source;
|
||||
//
|
||||
// source.start(0);
|
||||
//
|
||||
// return new Promise((resolve) => {
|
||||
// source.onended = () => {
|
||||
// if (this.currentSource === source) {
|
||||
// this.currentSource = null;
|
||||
// }
|
||||
// resolve();
|
||||
// };
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// stopKeepAlive() {
|
||||
// if (this.keepAliveSource) {
|
||||
// try {
|
||||
// this.keepAliveSource.stop();
|
||||
// } catch (_) {}
|
||||
//
|
||||
// this.keepAliveSource = null;
|
||||
// this.keepAliveGain = null;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,14 @@
|
||||
export const eventBus = new EventTarget()
|
||||
|
||||
const FilterSelectedEvent = 'FilterSelected'
|
||||
|
||||
const CallLoadingEvent = 'CallLoading'
|
||||
|
||||
export function RegistLoading(func) {
|
||||
eventBus.addEventListener(CallLoadingEvent, (event) => {
|
||||
func(event.detail)
|
||||
})
|
||||
}
|
||||
export function triggerCallLoading(bLoading) {
|
||||
eventBus.dispatchEvent(new CustomEvent(CallLoadingEvent, { detail: bLoading }))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {createContext,useContext} from 'react';
|
||||
|
||||
export const GContext = createContext(null);
|
||||
|
||||
export function useGContext() {
|
||||
return useContext(GContext);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 使用方法:
|
||||
* - 导入该http组件
|
||||
* import http from '@/utils/http';
|
||||
* - 发 GET 自动带cookie
|
||||
* const { data } = await http.get('/userinfo');
|
||||
* - 发 POST,也会自动带 cookie
|
||||
* await http.post('/posts', { title: 'hello' });
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: 'https://rop.cc/apitest/cookie',
|
||||
timeout: 5000,
|
||||
withCredentials: true //提示浏览器,带上cookie
|
||||
});
|
||||
|
||||
// 请求拦截器:自动附加 token
|
||||
// http.interceptors.request.use(cfg => {
|
||||
// cfg.headers['Authorization'] = `Bearer ${token}`;
|
||||
// return cfg;
|
||||
// }, err => Promise.reject(err));
|
||||
|
||||
// 响应拦截器:统一处理 401(未授权)
|
||||
http.interceptors.response.use(res => res, err => {
|
||||
// 检查错误类型是否为 AxiosError,并且是否为超时错误
|
||||
if (err.code === 'ECONNABORTED' && err.message.includes('timeout')) {
|
||||
// 超时错误的处理
|
||||
console.error('请求超时:', err.message);
|
||||
alert('请求超时,请稍后再试');
|
||||
return Promise.resolve(); // 返回一个 resolved promise,防止继续传播
|
||||
}
|
||||
if (err.response) {
|
||||
const status = err.response.status;
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 重定向到登录页
|
||||
window.location.href = '/apitest/login';
|
||||
break;
|
||||
case 403:
|
||||
alert('权限不足,请联系管理员');
|
||||
break;
|
||||
case 404:
|
||||
console.warn('请求的资源未找到');
|
||||
break;
|
||||
case 500:
|
||||
alert('服务器出错,请稍后再试');
|
||||
break;
|
||||
case 502:
|
||||
alert('网关错误,请稍后再试');
|
||||
break;
|
||||
case 503:
|
||||
alert('服务不可用,请稍后再试');
|
||||
break;
|
||||
case 504:
|
||||
alert('网关超时,请稍后再试');
|
||||
break;
|
||||
default:
|
||||
console.error('请求失败:', err);
|
||||
|
||||
}
|
||||
return Promise.resolve();
|
||||
|
||||
} else {
|
||||
// 非响应错误
|
||||
console.error('非响应错误:', err);
|
||||
// 处理非响应错误
|
||||
if (err instanceof Error) {
|
||||
alert('请求失败,请检查网络连接');
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
export default http;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 使用方法:
|
||||
* - 导入该http组件
|
||||
* import http from '@/utils/http';
|
||||
* - 发 GET 自动带Authorization 头
|
||||
* const { data } = await http.get('/userinfo');
|
||||
* - 发 POST,也会自动带 Authorization 头
|
||||
* await http.post('/posts', { title: 'hello' });
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
const http = axios.create({
|
||||
baseURL: 'https://voc.rop.cc/api',
|
||||
timeout: 5000
|
||||
});
|
||||
// 请求拦截器:自动附加 token
|
||||
http.interceptors.request.use(cfg => {
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (token) {
|
||||
cfg.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return cfg;
|
||||
}, err => Promise.reject(err));
|
||||
// 响应拦截器:统一处理 401(未授权)
|
||||
http.interceptors.response.use(res => res, err => {
|
||||
// 检查错误类型是否为 AxiosError,并且是否为超时错误
|
||||
if (err.code === 'ECONNABORTED' && err.message.includes('timeout')) {
|
||||
// 超时错误的处理
|
||||
console.error('请求超时:', err.message);
|
||||
alert('请求超时,请稍后再试');
|
||||
return Promise.resolve(); // 返回一个 resolved promise,防止继续传播
|
||||
}
|
||||
if (err.response) {
|
||||
const status = err.response.status;
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 清除可能已失效的 token
|
||||
localStorage.removeItem('jwt');
|
||||
// 重定向到登录页
|
||||
window.location.href = '/login';
|
||||
break;
|
||||
case 403:
|
||||
alert('权限不足,请联系管理员');
|
||||
break;
|
||||
case 404:
|
||||
console.warn('请求的资源未找到');
|
||||
break;
|
||||
case 500:
|
||||
alert('服务器出错,请稍后再试');
|
||||
break;
|
||||
case 502:
|
||||
alert('网关错误,请稍后再试');
|
||||
break;
|
||||
case 503:
|
||||
alert('服务不可用,请稍后再试');
|
||||
break;
|
||||
case 504:
|
||||
alert('网关超时,请稍后再试');
|
||||
break;
|
||||
default:
|
||||
console.error('请求失败:', err);
|
||||
|
||||
}
|
||||
return Promise.resolve();
|
||||
|
||||
} else {
|
||||
// 非响应错误
|
||||
console.error('非响应错误:', err);
|
||||
// 处理非响应错误
|
||||
if (err instanceof Error) {
|
||||
alert('请求失败,请检查网络连接');
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
export default http;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as saving from '@/utils/saving.jsx'
|
||||
|
||||
let currentAudio = null
|
||||
let currentUrl = null
|
||||
let playing = false
|
||||
|
||||
function stopCurrentPlay() {
|
||||
if (currentAudio) {
|
||||
currentAudio.pause()
|
||||
currentAudio.currentTime = 0
|
||||
|
||||
currentAudio.onended = null
|
||||
currentAudio.onerror = null
|
||||
}
|
||||
|
||||
if (currentUrl) {
|
||||
URL.revokeObjectURL(currentUrl)
|
||||
}
|
||||
|
||||
currentAudio = null
|
||||
currentUrl = null
|
||||
playing = false
|
||||
}
|
||||
|
||||
export async function play(msgid) {
|
||||
if (playing) {
|
||||
stopCurrentPlay()
|
||||
}
|
||||
|
||||
const blob = await saving.getAudioBlob(msgid)
|
||||
if (!blob) return
|
||||
|
||||
const url = URL.createObjectURL(blob.blob)
|
||||
const audio = new Audio(url)
|
||||
|
||||
currentAudio = audio
|
||||
currentUrl = url
|
||||
playing = true
|
||||
|
||||
audio.onended = () => {
|
||||
stopCurrentPlay()
|
||||
}
|
||||
|
||||
audio.onerror = () => {
|
||||
console.error("音频播放失败")
|
||||
stopCurrentPlay()
|
||||
}
|
||||
|
||||
try {
|
||||
await audio.play()
|
||||
} catch (err) {
|
||||
console.error("音频播放失败", err)
|
||||
stopCurrentPlay()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
export class Recorder {
|
||||
|
||||
constructor(options = {}) {
|
||||
this.autoPlay = options.autoPlay ?? true;
|
||||
this.onStateChange = options.onStateChange || (() => { });
|
||||
this.startPromise = null;
|
||||
this.recordStartTime = 0;
|
||||
this.mediaStream = null;
|
||||
this.mediaRecorder = null;
|
||||
this.recordedChunks = [];
|
||||
/** this.state:
|
||||
* - invalid
|
||||
* - idle
|
||||
* - starting
|
||||
* - recording
|
||||
*/
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
console.error('当前环境不支持录音,请使用 HTTPS 或 localhost 访问');
|
||||
this.setState('invalid')
|
||||
return;
|
||||
}
|
||||
const p = navigator.mediaDevices.getUserMedia({audio: true});
|
||||
p.then((stream) => {
|
||||
// 只是申请权限,不是真的开始录音,所以立刻关闭麦克风
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
this.setState('idle')
|
||||
console.log('麦克风权限已允许');
|
||||
}).catch((err) => {
|
||||
this.setState('invalid')
|
||||
console.error('麦克风权限被拒绝或申请失败:', err);
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
setState(nextState) {
|
||||
this.state = nextState;
|
||||
this.onStateChange(nextState);
|
||||
}
|
||||
|
||||
/** 停止麦克风轨道,避免浏览器一直显示录音占用。 */
|
||||
stopTracks() {
|
||||
this.mediaStream?.getTracks().forEach((track) => track.stop());
|
||||
this.mediaStream = null;
|
||||
}
|
||||
|
||||
/** 页面卸载或组件销毁时调用。 */
|
||||
close() {
|
||||
if (this.mediaRecorder?.state === 'recording') this.mediaRecorder.stop();
|
||||
this.stopTracks();
|
||||
}
|
||||
|
||||
async startRecorder() {
|
||||
if (this.state === 'recording' || this.state === 'starting') {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
if (this.state !== 'idle') {
|
||||
throw new Error('当前无法使用录音');
|
||||
}
|
||||
|
||||
this.setState('starting');
|
||||
|
||||
this.startPromise = this._doStartRecorder();
|
||||
|
||||
try {
|
||||
await this.startPromise;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async getMicStream() {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
const audioInputs = devices.filter(device => device.kind === 'audioinput');
|
||||
|
||||
const hasDefaultDevice = audioInputs.some(device => device.deviceId === 'default');
|
||||
|
||||
// Chrome 桌面端通常会有 deviceId === 'default' 的虚拟默认设备
|
||||
if (hasDefaultDevice) {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: {exact: 'default'},
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('使用 default 麦克风失败,回退到普通 audio:true', {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
constraint: err.constraint
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 手机端或不支持 default 设备的浏览器走这里
|
||||
return await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _doStartRecorder() {
|
||||
try {
|
||||
this.mediaStream = await this.getMicStream()
|
||||
|
||||
this.recordedChunks = [];
|
||||
|
||||
const candidates = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4'
|
||||
];
|
||||
|
||||
const mimeType =
|
||||
candidates.find(type => MediaRecorder.isTypeSupported(type)) || '';
|
||||
|
||||
this.mediaRecorder = new MediaRecorder(
|
||||
this.mediaStream,
|
||||
mimeType ? {mimeType} : undefined
|
||||
);
|
||||
|
||||
console.log('MIME type =', this.mediaRecorder.mimeType || mimeType);
|
||||
|
||||
this.mediaRecorder.addEventListener('dataavailable', event => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
this.recordedChunks.push(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.mediaRecorder.start();
|
||||
this.recordStartTime = performance.now();
|
||||
|
||||
this.setState('recording');
|
||||
} catch (err) {
|
||||
this.stopTracks();
|
||||
this.mediaRecorder = null;
|
||||
this.recordedChunks = [];
|
||||
this.setState('idle');
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
async stopRecording() {
|
||||
// 如果还在启动中,先等启动完成
|
||||
if (this.state === 'starting' && this.startPromise) {
|
||||
await this.startPromise;
|
||||
}
|
||||
|
||||
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') {
|
||||
throw new Error('当前没有正在进行的录音');
|
||||
}
|
||||
|
||||
if (this.state !== 'recording') {
|
||||
throw new Error('当前没有正在进行的录音');
|
||||
}
|
||||
|
||||
this.setState('stopping');
|
||||
|
||||
const recorder = this.mediaRecorder;
|
||||
|
||||
const stopped = new Promise((resolve, reject) => {
|
||||
recorder.addEventListener('stop', resolve, {once: true});
|
||||
recorder.addEventListener('error', reject, {once: true});
|
||||
});
|
||||
|
||||
recorder.stop();
|
||||
|
||||
await stopped;
|
||||
|
||||
this.stopTracks();
|
||||
|
||||
const type = recorder.mimeType || 'audio/webm';
|
||||
const blob = new Blob(this.recordedChunks, {type});
|
||||
|
||||
this.mediaRecorder = null;
|
||||
this.recordedChunks = [];
|
||||
this.setState('idle');
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// async startRecorder() {
|
||||
// if (this.state !== 'idle') {
|
||||
// throw new Error('当前无法使用录音')
|
||||
// }
|
||||
// if (this.mediaRecorder?.state === 'recording') return;
|
||||
//
|
||||
// this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
// audio: {deviceId: {exact: 'default'}}
|
||||
// });
|
||||
// this.recordedChunks = [];
|
||||
//
|
||||
// const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'];
|
||||
// const mimeType = candidates.find((type) => MediaRecorder.isTypeSupported(type)) || '';
|
||||
// this.mediaRecorder = new MediaRecorder(this.mediaStream, mimeType ? {mimeType} : undefined);
|
||||
//
|
||||
// console.log("MIME type =", mimeType)
|
||||
//
|
||||
// this.mediaRecorder.addEventListener('dataavailable', (event) => {
|
||||
// if (event.data?.size > 0) this.recordedChunks.push(event.data);
|
||||
// });
|
||||
//
|
||||
// this.mediaRecorder.start();
|
||||
// this.setState('recording');
|
||||
// }
|
||||
//
|
||||
// /** 只停止录音并返回音频 Blob;适合需要先预览再发送的界面。 */
|
||||
// async stopRecording() {
|
||||
// if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') {
|
||||
// throw new Error('当前没有正在进行的录音');
|
||||
// }
|
||||
//
|
||||
// const stopped = new Promise((resolve) => {
|
||||
// this.mediaRecorder.addEventListener('stop', resolve, {once: true});
|
||||
// });
|
||||
//
|
||||
// this.mediaRecorder.stop();
|
||||
// await stopped;
|
||||
// this.stopTracks();
|
||||
//
|
||||
// const type = this.mediaRecorder.mimeType || 'audio/webm';
|
||||
// const blob = new Blob(this.recordedChunks, {type});
|
||||
// this.setState('idle');
|
||||
// return blob;
|
||||
// }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//
|
||||
import * as audiodb from '@/utils/audioDB.js'
|
||||
|
||||
|
||||
/**
|
||||
* localStorage.threads<section> []
|
||||
* section 对象定义:
|
||||
* {
|
||||
* threadid : string,
|
||||
* messages : [
|
||||
* {messageid:"xxxx",role:"user"|"assistant",content:"xxxx",hasaudio:true|false},
|
||||
* {messageid:"xxxx",role:"user"|"assistant",content:"xxxx",hasaudio:true|false}
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
|
||||
|
||||
function loadThreads() {
|
||||
let threads = localStorage.getItem('threads')
|
||||
if (!threads) {
|
||||
threads = JSON.stringify([])
|
||||
localStorage.setItem('threads', threads)
|
||||
}
|
||||
return JSON.parse(threads)
|
||||
}
|
||||
|
||||
export function listupthreads() {
|
||||
|
||||
const threads = localStorage.getItem("threads")
|
||||
if (!threads) {
|
||||
localStorage.setItem("threads", JSON.stringify([]));
|
||||
return []
|
||||
}
|
||||
|
||||
return JSON.parse(threads);
|
||||
}
|
||||
|
||||
/** message
|
||||
* - messageid
|
||||
* - role
|
||||
* - content
|
||||
* - hasaudio : true|false
|
||||
*/
|
||||
export async function addMessage(threadid, message, audio) {
|
||||
if (!message || !threadid) return false;
|
||||
|
||||
// console.log("[[saving.addMessage]] threadid =", threadid)
|
||||
let threads = loadThreads()
|
||||
|
||||
|
||||
let _section = threads.find(item => item.threadid === threadid)
|
||||
if (!_section) {
|
||||
console.log("未找到threadid,添加新thread")
|
||||
_section = {
|
||||
threadid: threadid,
|
||||
messages: []
|
||||
}
|
||||
threads.push(_section)
|
||||
}
|
||||
const exist_msg = _section.messages.filter(item => item.messageid === message.messageid)
|
||||
if (exist_msg.length > 0) {
|
||||
console.log("existing message id")
|
||||
return false
|
||||
}
|
||||
_section.messages.push(message)
|
||||
if (audio && message.hasaudio) {
|
||||
await audiodb.saveAudio(message.messageid, audio)
|
||||
}
|
||||
localStorage.setItem("threads", JSON.stringify(threads))
|
||||
}
|
||||
|
||||
export function getSelectedThread() {
|
||||
let _lvar = localStorage.getItem('selected_threadid')
|
||||
if (!_lvar) {
|
||||
_lvar = ''
|
||||
localStorage.setItem('selected_threadid', 'New Thread')
|
||||
}
|
||||
return _lvar
|
||||
}
|
||||
|
||||
export function deleteThread(threadid) {
|
||||
let threads = loadThreads()
|
||||
let thr = threads.find(item => (item.threadid === threadid))
|
||||
if(!thr){
|
||||
return false
|
||||
}else{
|
||||
let newThreads = threads.filter(item=> item.threadid!== threadid)
|
||||
if (newThreads.length === 0) {
|
||||
newThreads = []
|
||||
}
|
||||
localStorage.setItem('threads', JSON.stringify(newThreads))
|
||||
}
|
||||
}
|
||||
|
||||
export function setSelectedThread(threadid) {
|
||||
localStorage.setItem('selected_threadid', threadid)
|
||||
}
|
||||
|
||||
export function getThread(threadid) {
|
||||
let _threads = localStorage.getItem('threads')
|
||||
if (!_threads) {
|
||||
_threads = '[]'
|
||||
localStorage.setItem('threads', _threads)
|
||||
}
|
||||
let threads = JSON.parse(_threads)
|
||||
const thread = threads.find(item => item.threadid === threadid)
|
||||
|
||||
if (thread) {
|
||||
return thread
|
||||
} else {
|
||||
console.warn("[[saving.getThread]] 没找到要找到Thread:", threadid)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function checkThreadid(threadid) {
|
||||
let _threads = localStorage.getItem('threads')
|
||||
if (!_threads) {
|
||||
_threads = '[]'
|
||||
localStorage.setItem('threads', _threads)
|
||||
}
|
||||
let threads = JSON.parse(_threads)
|
||||
const thread = threads.find(item => item.threadid === threadid)
|
||||
return !!thread;
|
||||
}
|
||||
|
||||
export async function getAudioBlob(msgid) {
|
||||
try {
|
||||
return await audiodb.getAudio(msgid)
|
||||
} catch (error) {
|
||||
console.error("getAudioBlob error:", error)
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import {fileURLToPath, URL} from 'node:url';
|
||||
import {VitePWA} from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'AI Language Chat',
|
||||
short_name: 'AI Lang',
|
||||
description: 'Chat with AI to learn foreign languages',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#2563EB',
|
||||
icons: [
|
||||
{
|
||||
src: '/android-chrome-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/android-chrome-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/android-chrome-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5223,
|
||||
strictPort: true //如果端口被占直接报错,不自动换
|
||||
}
|
||||
})
|
||||