feat: update dependencies and add video-tools package

- Added "@kevisual/video-tools" dependency to server/package.json.
- Updated VITE_API_URL in astro.config.mjs to point to new local server.
- Modified proxy settings in astro.config.mjs to include WebSocket support.
- Updated various dependencies in web/package.json for improved functionality and security.
- Refactored MuseApp component to use new resizable panel components.
- Implemented real-time text display and copy functionality in VadVoice component.
- Created a new Relatime class for handling WebSocket connections and real-time updates.
- Added new board and muse pages to render the Record component.
This commit is contained in:
2025-12-23 00:40:52 +08:00
parent 6a1e648747
commit 4407c6157f
14 changed files with 1448 additions and 939 deletions

4
.gitignore vendored
View File

@@ -4,8 +4,4 @@ dist
.turbo .turbo
filebrowser
filebrowser.db
/data /data

View File

@@ -15,9 +15,9 @@
"keywords": [], "keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)", "author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT", "license": "MIT",
"packageManager": "pnpm@10.18.3", "packageManager": "pnpm@10.26.1",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"turbo": "^2.5.8" "turbo": "^2.7.1"
} }
} }

2071
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@
"@kevisual/noco": "^0.0.1", "@kevisual/noco": "^0.0.1",
"@kevisual/query": "^0.0.29", "@kevisual/query": "^0.0.29",
"@kevisual/router": "^0.0.29", "@kevisual/router": "^0.0.29",
"@kevisual/video-tools": "^0.0.10",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"unstorage": "^1.17.1" "unstorage": "^1.17.1"

View File

@@ -7,13 +7,13 @@ import tailwindcss from '@tailwindcss/vite';
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
let target = process.env.VITE_API_URL || 'http://localhost:4005'; let target = process.env.VITE_API_URL || 'http://localhost:51015';
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' }; const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
let proxy = { let proxy = {
'/root/': { '/root': apiProxy,
target: `${target}/root/`,
},
'/api': apiProxy, '/api': apiProxy,
'/ws': apiProxy,
}; };
const basename = isDev ? undefined : `${pkgs.basename}`; const basename = isDev ? undefined : `${pkgs.basename}`;
@@ -25,15 +25,18 @@ export default defineConfig({
// sitemap(), // sitemap must be site has a domain // sitemap(), // sitemap must be site has a domain
], ],
server: {
port: 4321,
host: '0.0.0.0',
allowedHosts: true,
},
vite: { vite: {
plugins: [tailwindcss()], plugins: [tailwindcss()],
define: { define: {
basename: JSON.stringify(basename || ''), basename: JSON.stringify(basename || ''),
}, },
server: { server: {
port: 7008,
host: '0.0.0.0',
allowedHosts: true,
proxy, proxy,
}, },
}, },

View File

@@ -17,49 +17,50 @@
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@astrojs/mdx": "^4.3.7", "@astrojs/mdx": "^4.3.13",
"@astrojs/react": "^4.4.0", "@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.6.0", "@astrojs/sitemap": "^3.6.0",
"@faker-js/faker": "^10.1.0", "@faker-js/faker": "^10.1.0",
"@floating-ui/react": "^0.27.16", "@floating-ui/react": "^0.27.16",
"@kevisual/noco": "^0.0.1", "@kevisual/noco": "^0.0.10",
"@kevisual/query": "^0.0.29", "@kevisual/query": "^0.0.33",
"@kevisual/query-login": "^0.0.6", "@kevisual/query-login": "^0.0.7",
"@kevisual/registry": "^0.0.1", "@kevisual/registry": "^0.0.1",
"@kevisual/video-tools": "^0.0.11",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.4",
"@ricky0123/vad-web": "^0.0.28", "@ricky0123/vad-web": "^0.0.30",
"@szhsin/react-menu": "^4.5.0", "@szhsin/react-menu": "^4.5.1",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-form": "^1.23.7", "@tanstack/react-form": "^1.27.5",
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.12",
"astro": "^5.14.4", "astro": "^5.16.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.19",
"es-toolkit": "^1.40.0", "es-toolkit": "^1.43.0",
"events": "^3.3.0", "events": "^3.3.0",
"graphology": "^0.26.0", "graphology": "^0.26.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.22",
"lucide-react": "^0.545.0", "lucide-react": "^0.562.0",
"marked": "^16.4.1", "marked": "^17.0.1",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.5",
"pouchdb-adapter-memory": "^9.0.0", "pouchdb-adapter-memory": "^9.0.0",
"pouchdb-browser": "^9.0.0", "pouchdb-browser": "^9.0.0",
"react": "^19.2.0", "react": "^19.2.3",
"react-dom": "^19.2.0", "react-dom": "^19.2.3",
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^4.0.13",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"react-virtualized": "^9.22.6", "react-virtualized": "^9.22.6",
"sigma": "^3.0.2", "sigma": "^3.0.2",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.4.0",
"three": "^0.180.0", "three": "^0.182.0",
"wavesurfer.js": "^7.11.0", "wavesurfer.js": "^7.12.1",
"zustand": "^5.0.8" "zustand": "^5.0.9"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
@@ -68,18 +69,18 @@
"@kevisual/types": "^0.0.10", "@kevisual/types": "^0.0.10",
"@types/pouchdb": "^6.4.2", "@types/pouchdb": "^6.4.2",
"@types/pouchdb-browser": "^6.1.5", "@types/pouchdb-browser": "^6.1.5",
"@types/react": "^19.2.2", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.3",
"@types/react-modal": "^3.16.3", "@types/react-modal": "^3.16.3",
"@types/react-virtualized": "^9.22.3", "@types/react-virtualized": "^9.22.3",
"@types/three": "^0.180.0", "@types/three": "^0.182.0",
"@vitejs/plugin-basic-ssl": "^2.1.0", "@vitejs/plugin-basic-ssl": "^2.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"pouchdb": "^9.0.0", "pouchdb": "^9.0.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"packageManager": "pnpm@10.18.3", "packageManager": "pnpm@10.26.1",
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@tailwindcss/oxide", "@tailwindcss/oxide",
"esbuild", "esbuild",

View File

@@ -1,6 +1,6 @@
import { ToastContainer, toast } from 'react-toastify'; import { ToastContainer, toast } from 'react-toastify';
import { AuthProvider } from '../login/AuthProvider'; import { AuthProvider } from '../login/AuthProvider';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Panel, Group, Separator } from 'react-resizable-panels';
import { useRef } from 'react'; import { useRef } from 'react';
import { App as Voice } from './voice/index.tsx'; import { App as Voice } from './voice/index.tsx';
@@ -149,20 +149,20 @@ export const MuseApp = () => {
}}> }}>
DB DB
</button> </button>
<button <button
className="px-3 py-1 rounded text-sm bg-blue-500 text-white hover:bg-blue-600" className="px-3 py-1 rounded text-sm bg-blue-500 text-white hover:bg-blue-600"
onClick={handleExportDB} onClick={handleExportDB}
> >
DB DB
</button> </button>
<button <button
className="px-3 py-1 rounded text-sm bg-purple-500 text-white hover:bg-purple-600" className="px-3 py-1 rounded text-sm bg-purple-500 text-white hover:bg-purple-600"
onClick={handleImportDB} onClick={handleImportDB}
> >
DB DB
</button> </button>
<button <button
className="px-3 py-1 rounded text-sm bg-red-500 text-white hover:bg-red-600" className="px-3 py-1 rounded text-sm bg-red-500 text-white hover:bg-red-600"
onClick={handleDeleteDB} onClick={handleDeleteDB}
> >
DB DB
@@ -180,21 +180,21 @@ export const MuseApp = () => {
{/* Resizable Panels */} {/* Resizable Panels */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<PanelGroup direction="horizontal"> <Group orientation="horizontal">
{showLeftPanel && <LeftPanel />} {showLeftPanel && <LeftPanel />}
{showLeftPanel && showCenterPanel && ( {showLeftPanel && showCenterPanel && (
<PanelResizeHandle className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" /> <Separator className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" />
)} )}
{showCenterPanel && <CenterPanel />} {showCenterPanel && <CenterPanel />}
{showCenterPanel && showRightPanel && ( {showCenterPanel && showRightPanel && (
<PanelResizeHandle className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" /> <Separator className="w-1 bg-gray-300 hover:bg-gray-400 cursor-col-resize transition-colors" />
)} )}
{showRightPanel && <RightPanel isVisible={showRightPanel} />} {showRightPanel && <RightPanel isVisible={showRightPanel} />}
</PanelGroup> </Group>
</div> </div>
</div> </div>
); );
@@ -219,4 +219,24 @@ export const App: React.FC = () => {
/> />
</AuthProvider> </AuthProvider>
); );
}; };
export const Record: React.FC = () => {
return (
<AuthProvider>
<Voice />
<ToastContainer
position="top-right"
autoClose={1000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
/>
</AuthProvider>
);
}

View File

@@ -2,7 +2,7 @@ import { MicVAD, utils } from "@ricky0123/vad-web"
import clsx from "clsx"; import clsx from "clsx";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import './style.css' import './style.css'
import { MoreHorizontal, Play, Pause, Settings, FileAudio, StopCircle, Loader } from "lucide-react"; import { MoreHorizontal, Play, Pause, Settings, FileAudio, StopCircle, Loader, Copy } from "lucide-react";
import { Menu, MenuItem, MenuButton, } from '@szhsin/react-menu'; import { Menu, MenuItem, MenuButton, } from '@szhsin/react-menu';
import '@szhsin/react-menu/dist/index.css'; import '@szhsin/react-menu/dist/index.css';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -223,6 +223,12 @@ const VoicePlayer = ({ data }: VadVoiceProps) => {
); );
} }
export const ShowVoicePlayer = ({ data }: { data: Speak[] }) => { export const ShowVoicePlayer = ({ data }: { data: Speak[] }) => {
useEffect(() => {
const bottomElement = document.getElementById('voice-list-bottom');
if (bottomElement) {
bottomElement.scrollIntoView({ behavior: 'smooth' });
}
}, [data]);
return (<ul className="space-y-2 max-h-full"> return (<ul className="space-y-2 max-h-full">
{data.map((item, index) => ( {data.map((item, index) => (
<li key={index} className="bg-white rounded-lg border border-gray-200 p-2 hover:shadow-sm transition-shadow"> <li key={index} className="bg-white rounded-lg border border-gray-200 p-2 hover:shadow-sm transition-shadow">
@@ -343,6 +349,8 @@ export const ShowVoicePlayer = ({ data }: { data: Speak[] }) => {
</div> </div>
</li> </li>
))} ))}
<div id="voice-list-bottom" />
</ul>) </ul>)
} }
export const VadVoice = () => { export const VadVoice = () => {
@@ -353,9 +361,11 @@ export const VadVoice = () => {
error: storeError, error: storeError,
initialize: initializeStore, initialize: initializeStore,
addVoice, addVoice,
setError: setStoreError setError: setStoreError,
relatimeParialText,
relatimeFinalText
} = useVoiceStore(); } = useVoiceStore();
const showText = relatimeFinalText || relatimeParialText;
// 使用设置 store // 使用设置 store
const { const {
openModal: openSettingModal, openModal: openSettingModal,
@@ -391,7 +401,8 @@ export const VadVoice = () => {
const wavBuffer = utils.encodeWAV(audio) const wavBuffer = utils.encodeWAV(audio)
const audioBlob = new Blob([wavBuffer], { type: 'audio/wav' }) const audioBlob = new Blob([wavBuffer], { type: 'audio/wav' })
const tempUrl = URL.createObjectURL(audioBlob) const tempUrl = URL.createObjectURL(audioBlob)
const relatime = useVoiceStore.getState().relatime;
relatime?.sendBase64?.(utils.arrayBufferToBase64(wavBuffer));
// 从实际音频文件获取准确时长 // 从实际音频文件获取准确时长
const getDuration = (): Promise<number> => { const getDuration = (): Promise<number> => {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -508,7 +519,7 @@ export const VadVoice = () => {
}; };
return <div className="h-full flex flex-col"> return <div className="h-full flex flex-col">
{/* Audio Recordings List */} {/* Audio Recordings List */}
<div className="flex-1 overflow-y-auto px-2 py-3 min-h-0 max-h-200"> <div className="flex-1 overflow-y-auto px-2 py-3 min-h-0 max-h-76 overflow-hidden">
{ {
voiceList.length === 0 ? ( voiceList.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-8"> <div className="text-center text-gray-400 text-sm py-8">
@@ -564,6 +575,29 @@ export const VadVoice = () => {
`${voiceList.length} recording${voiceList.length !== 1 ? 's' : ''}` `${voiceList.length} recording${voiceList.length !== 1 ? 's' : ''}`
)} )}
</div> </div>
<div className=" ">
{showText && (
<div className="flex">
<div className="text-xs text-gray-600 mt-1 truncate">
📝 {showText}
</div>
<div className="cursor-pointer" onClick={() => {
// copy
navigator.clipboard.writeText(showText).then(() => {
toast.success('已复制', {
autoClose: 500,
position: 'top-center'
});
}).catch((error) => {
console.error('复制失败:', error);
toast.error('复制失败,请手动选择文字复制');
});
}}>
<Copy />
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -572,7 +606,7 @@ export const VadVoice = () => {
onClick={retryInitialization} onClick={retryInitialization}
className="px-2 py-1 text-xs font-medium text-blue-700 bg-blue-100 hover:bg-blue-200 rounded-md transition-colors" className="px-2 py-1 text-xs font-medium text-blue-700 bg-blue-100 hover:bg-blue-200 rounded-md transition-colors"
> >
Retry
</button> </button>
)} )}
<button <button

View File

@@ -1,26 +1,10 @@
// Speak 数据库和服务的统一导出文件 // Speak 数据库和服务的统一导出文件
// 类型定义 // 类型定义
export { export * from './speak.ts';
Speak,
SpeakType,
CreateSpeakData,
UpdateSpeakData,
getDayOfYear
} from './speak';
// 数据库操作 // 数据库操作
export { export * from './speak-db';
SpeakDB,
SpeakDocument,
speakDB,
initSpeakDB,
createSpeakDB
} from './speak-db';
// 服务层 // 服务层
export { export * from './speak-service';
SpeakService,
speakService,
exampleUsage
} from './speak-service';

View File

@@ -0,0 +1,65 @@
import { WSServer } from '@kevisual/video-tools/src/asr/ws.ts'
import { useVoiceStore } from './voiceStore'
export class Relatime {
asr: WSServer
ready = false
timeoutHandle: NodeJS.Timeout | null = null
constructor() {
// const url = new URL('/ws/asr', "http://localhost:51015")
const url = new URL('/ws/asr', window.location.origin)
url.searchParams.set('id', 'muse-voice-relatime')
const ws = new WSServer({
url: url.toString(),
onConnect: () => {
console.log('WebSocket connected');
ws.emitter.on("message", (data) => {
// console.log("Received message:", data.data);
const json = JSON.parse(data.data);
console.log('json', json);
if (json && json.type === 'connected') {
ws.ws.send(JSON.stringify({ type: 'init' }));
}
if (json && json.type === 'asr' && json.code === 200) {
ws.emitter.emit('asr');
}
if (json && json.type === 'partial' || json.type === 'result') {
const text = json.text || '';
const isPartial = json.type === 'partial';
const isResult = json.type === 'result';
if (isPartial) {
// 部分结果
useVoiceStore.getState().setRelatimeParialText(text);
} else {
// 最终结果
useVoiceStore.getState().setRelatimeFinalText(text);
}
}
});
ws.emitter.once('asr', async () => {
console.log('ASR ready');
this.ready = true;
});
}
})
this.asr = ws
}
send(data: Buffer) {
if (!this.ready) return;
const voice = data.toString('base64');
this.asr.ws.send(JSON.stringify({ voice }));
}
sendBase64(data: string) {
if (!this.ready) return;
this.asr.ws.send(JSON.stringify({ voice: data, format: 'float32' }));
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle);
}
this.timeoutHandle = setTimeout(() => {
this.asr.sendBlankJson()
this.timeoutHandle = null;
}, 10000); // 5秒钟没有数据则发送空JSON保持连接
}
}

View File

@@ -4,6 +4,7 @@ import { Speak, getDayOfYear, CreateSpeakData } from '../modules/speak-db/speak'
import { speakService } from '../modules/speak-db/speak-service'; import { speakService } from '../modules/speak-db/speak-service';
import { getText } from '../modules/text'; import { getText } from '../modules/text';
import { useSettingStore } from './settingStore'; import { useSettingStore } from './settingStore';
import { Relatime } from './relatime';
interface VoiceState { interface VoiceState {
// 状态数据 // 状态数据
@@ -23,19 +24,29 @@ interface VoiceState {
refreshList: () => Promise<void>; refreshList: () => Promise<void>;
setError: (error: string | null) => void; setError: (error: string | null) => void;
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
relatime: Relatime;
relatimeParialText: string;
relatimeFinalText: string;
setRelatimeParialText: (text: string) => void;
setRelatimeFinalText: (text: string) => void;
} }
// 辅助函数:将 Blob 转换为 base64 字符串 // 辅助函数:将 Blob 转换为 base64 字符串(兼容 Node.js
const blobToBase64 = (blob: Blob): Promise<string> => { const blobToBase64 = (blob: Blob): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const result = reader.result as string; const arrayBuffer = reader.result as ArrayBuffer;
// 移除 data URL 前缀,只保留 base64 数据 // 转换为 Uint8Array 再转为 base64与 Node.js Buffer.toString('base64') 一致
resolve(result.split(',')[1] || result); const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
resolve(window.btoa(binary));
}; };
reader.onerror = reject; reader.onerror = reject;
reader.readAsDataURL(blob); reader.readAsArrayBuffer(blob); // 使用 ArrayBuffer 而不是 DataURL
}); });
}; };
@@ -75,7 +86,8 @@ export const useVoiceStore = create<VoiceState>()(
// 初始化:从 IndexedDB 获取当天的记录 // 初始化:从 IndexedDB 获取当天的记录
initialize: async () => { initialize: async () => {
const { setLoading, setError, generateAudioUrls } = get(); const { setLoading, setError, generateAudioUrls } = get();
const relatime = new Relatime();
set({ relatime });
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -130,7 +142,6 @@ export const useVoiceStore = create<VoiceState>()(
if (autoRecognize) { if (autoRecognize) {
speakData.text = await getText(fileData || '').then(res => res.text); speakData.text = await getText(fileData || '').then(res => res.text);
} }
// 保存到 IndexedDB不包含 url // 保存到 IndexedDB不包含 url
const newSpeak = await speakService.createSpeakAuto(speakData); const newSpeak = await speakService.createSpeakAuto(speakData);
@@ -323,7 +334,16 @@ export const useVoiceStore = create<VoiceState>()(
// 设置加载状态 // 设置加载状态
setLoading: (loading: boolean) => { setLoading: (loading: boolean) => {
set({ isLoading: loading }); set({ isLoading: loading });
} },
relatimeFinalText: '',
setRelatimeFinalText: (text: string) => {
set({ relatimeFinalText: text, relatimeParialText: '' });
},
setRelatimeParialText: (text: string) => {
set({ relatimeFinalText: '', relatimeParialText: text });
},
relatimeParialText: '',
}), }),
{ {
name: 'voice-store', // persist key name: 'voice-store', // persist key

View File

@@ -0,0 +1,8 @@
---
import Html from '../components/html.astro';
import { App } from '@/apps/muse/index.tsx';
---
<Html>
<App client:only />
</Html>

View File

@@ -1,8 +1,8 @@
--- ---
import Html from '../components/html.astro'; import Html from '../components/html.astro';
import { App } from '@/apps/muse/index.tsx'; import { Record } from '@/apps/muse/index.tsx';
--- ---
<Html> <Html>
<App client:only /> <Record client:only />
</Html> </Html>

View File

@@ -1,8 +1,8 @@
--- ---
import Html from '../components/html.astro'; import Html from '../components/html.astro';
import { App } from '@/apps/muse/index.tsx'; import { Record } from '@/apps/muse/index.tsx';
--- ---
<Html> <Html>
<App client:only /> <Record client:only />
</Html> </Html>