Init Commit

This commit is contained in:
2026-06-04 16:26:23 +09:00
commit 1e64827f57
64 changed files with 10718 additions and 0 deletions
+24
View File
@@ -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?
+16
View File
@@ -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.
+24
View File
@@ -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',
}
},
])
+20
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [ "src/*" ]
},
"jsx": "react"
}
}
+8109
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+45
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+24
View File
@@ -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

+19
View File
@@ -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"
}
+112
View File
@@ -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>
)
}
+26
View File
@@ -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;
+75
View File
@@ -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: '未知错误'}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+43
View File
@@ -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;
}
+86
View File
@@ -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>)
})
+24
View File
@@ -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;*/
/*}*/
+20
View File
@@ -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>)
}
+16
View File
@@ -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;
}
+73
View File
@@ -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>
);
}
+62
View File
@@ -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;
}
+65
View File
@@ -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>)
}
+11
View File
@@ -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 />;
}
+45
View File
@@ -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;
}
}
+20
View File
@@ -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>
);
+57
View File
@@ -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;
}
}
+301
View File
@@ -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>
)
}
//
+82
View File
@@ -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>
// );
// }
+10
View File
@@ -0,0 +1,10 @@
.login {
justify-self: center;
display: flex;
flex-direction: column;
max-width: 300px;
margin: 100px;
gap: 20px;
}
+47
View File
@@ -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>);
}
+45
View File
@@ -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);
}
+29
View File
@@ -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>)
}
View File
View File
+51
View File
@@ -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>
);
}
+35
View File
@@ -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;
}
+62
View File
@@ -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>)
}
+116
View File
@@ -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);
};
});
}
+207
View File
@@ -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;
// }
// }
// }
+14
View File
@@ -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 }))
}
+7
View File
@@ -0,0 +1,7 @@
import {createContext,useContext} from 'react';
export const GContext = createContext(null);
export function useGContext() {
return useContext(GContext);
}
+77
View File
@@ -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;
+78
View File
@@ -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;
+55
View File
@@ -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()
}
}
+241
View File
@@ -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;
// }
}
+146
View File
@@ -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
}
}
+52
View File
@@ -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 //如果端口被占直接报错,不自动换
}
})