update 优化录音功能模块的代码和火山模块

This commit is contained in:
2025-10-22 03:24:08 +08:00
parent c1072c3896
commit edace856ab
26 changed files with 1634 additions and 101 deletions

View File

@@ -9,6 +9,7 @@
"build": "astro build",
"preview": "astro preview",
"pub": "envision deploy ./dist -k light-code-center -v 0.0.1 -u",
"ui": "pnpm dlx shadcn@latest add ",
"sn": "pnpm dlx shadcn@latest add "
},
"keywords": [],
@@ -25,9 +26,14 @@
"@kevisual/query": "^0.0.29",
"@kevisual/query-login": "^0.0.6",
"@kevisual/registry": "^0.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@ricky0123/vad-web": "^0.0.28",
"@szhsin/react-menu": "^4.5.0",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-form": "^1.23.7",
"@tanstack/react-query": "^5.90.5",
"astro": "^5.14.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -333,8 +333,8 @@ export const Table: React.FC<TableProps> = ({
// Ctrl/Cmd + A 全选
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
event.preventDefault();
handleSelectAll(true);
// event.preventDefault();
// handleSelectAll(true);
return;
}

View File

@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
interface PasswordInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export const PasswordInput: React.FC<PasswordInputProps> = ({
value,
onChange,
placeholder,
className = '',
}) => {
const [showPassword, setShowPassword] = useState(false);
const togglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
return (
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`pr-10 ${className}`}
/>
<button
type="button"
onClick={togglePasswordVisibility}
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1 hover:bg-gray-100 rounded transition-colors"
title={showPassword ? '隐藏密码' : '显示密码'}
>
{showPassword ? (
<EyeOff className="w-4 h-4 text-gray-500" />
) : (
<Eye className="w-4 h-4 text-gray-500" />
)}
</button>
</div>
);
};

View File

@@ -3,7 +3,7 @@ import { AuthProvider } from '../login/AuthProvider';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useState, useRef } from 'react';
import { App as Voice } from './videos/index.tsx';
import { App as Voice } from './voice/index.tsx';
import { ChatInterface } from './prompts/index.tsx';
import { BaseApp } from './base/index.tsx';
import { exampleUsage, markService } from './modules/mark-service.ts';
@@ -201,7 +201,7 @@ export const App: React.FC = () => {
<MuseApp />
<ToastContainer
position="top-right"
autoClose={5000}
autoClose={1000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick

View File

@@ -1,5 +0,0 @@
@import 'tailwindcss';
.low-energy-spin {
animation: 2.5s linear 0s infinite normal forwards running spin;
}

View File

@@ -0,0 +1,148 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../../../components/ui/dialog";
import { useSettingStore } from '../store/settingStore';
import { PasswordInput } from '../../components/PasswordInput';
import { X, RotateCcw } from 'lucide-react';
export const SettingModal: React.FC = () => {
const {
isOpen,
autoRecognize,
listen,
volcengineAucAppId,
volcengineAucToken,
closeModal,
setAutoRecognize,
setListen,
setVolcengineAucAppId,
setVolcengineAucToken,
resetToDefault,
} = useSettingStore();
const handleClose = () => {
closeModal();
};
const handleReset = () => {
if (window.confirm('确定要重置所有设置为默认值吗?')) {
resetToDefault();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose} >
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto" showCloseButton={false}>
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle></DialogTitle>
<div className="flex items-center space-x-2">
<button
onClick={handleClose}
className="p-1 hover:bg-gray-100 rounded transition-colors cursor-pointer"
>
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
</div>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 语音识别设置 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm text-gray-700"></label>
<div className="relative">
<input
type="checkbox"
checked={autoRecognize}
onChange={(e) => setAutoRecognize(e.target.checked)}
className="sr-only"
/>
<div
onClick={() => setAutoRecognize(!autoRecognize)}
className={`w-11 h-6 rounded-full cursor-pointer transition-colors ${autoRecognize ? 'bg-blue-600' : 'bg-gray-200'
}`}
>
<div
className={`w-5 h-5 bg-white rounded-full shadow-md transform transition-transform ${autoRecognize ? 'translate-x-5' : 'translate-x-0.5'
} mt-0.5`}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-sm text-gray-700"></label>
<div className="relative">
<input
type="checkbox"
checked={listen}
onChange={(e) => setListen(e.target.checked)}
className="sr-only"
/>
<div
onClick={() => setListen(!listen)}
className={`w-11 h-6 rounded-full cursor-pointer transition-colors ${listen ? 'bg-blue-600' : 'bg-gray-200'
}`}
>
<div
className={`w-5 h-5 bg-white rounded-full shadow-md transform transition-transform ${listen ? 'translate-x-5' : 'translate-x-0.5'
} mt-0.5`}
/>
</div>
</div>
</div>
</div>
{/* 火山引擎配置 */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-900"></h3>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-700 mb-1">App ID</label>
<input
type="text"
value={volcengineAucAppId}
onChange={(e) => setVolcengineAucAppId(e.target.value)}
placeholder="请输入火山引擎 App ID"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Token</label>
<PasswordInput
value={volcengineAucToken}
onChange={setVolcengineAucToken}
placeholder="请输入火山引擎 Token"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
</button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -2,13 +2,26 @@ import { MicVAD, utils } from "@ricky0123/vad-web"
import clsx from "clsx";
import { useState, useEffect, useRef } from "react";
import './style.css'
import { MoreHorizontal, Play, Pause } from "lucide-react";
import { MoreHorizontal, Play, Pause, Settings, FileAudio, StopCircle, Loader } from "lucide-react";
import { Menu, MenuItem, MenuButton, } from '@szhsin/react-menu';
import '@szhsin/react-menu/dist/index.css';
import { toast } from 'react-toastify';
import { Speak } from "./speak-db/speak";
import { useVoiceStore } from "../store/voiceStore";
import { useSettingStore } from "../store/settingStore";
import { SettingModal } from "./SettingModal";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../../../../components/ui/alert-dialog";
type VadVoiceProps = {
data: Speak;
@@ -136,19 +149,51 @@ const VoicePlayer = ({ data }: VadVoiceProps) => {
<span></span>
</div>
</MenuItem>
<MenuItem onClick={handleDelete}>
<div className="flex items-center space-x-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1-1H8a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span></span>
</div>
</MenuItem>
{data.text && data.text.trim() ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<MenuItem>
<div className="flex items-center space-x-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1-1H8a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span></span>
</div>
</MenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{data.text}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<MenuItem onClick={handleDelete}>
<div className="flex items-center space-x-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1-1H8a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span></span>
</div>
</MenuItem>
)}
</Menu>
{/* 播放/暂停按钮 */}
{!isPlaying ? (
@@ -187,11 +232,92 @@ export const ShowVoicePlayer = ({ data }: { data: Speak[] }) => {
<VoicePlayer data={item} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-400 truncate">
{new Date(item.timestamp).toLocaleTimeString()}
</div>
<div className="text-xs text-gray-300">
#{item.no}
<div className="flex items-center justify-between space-x-2">
<div>
<div className="text-xs text-gray-400 truncate">
{new Date(item.timestamp).toLocaleTimeString()}
</div>
<div className="text-xs text-gray-300">
#{item.no}
</div>
</div>
<div className="flex items-center space-x-1">
{item.text && item.text.trim() ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="w-5 h-5 hover:bg-red-100 rounded flex items-center justify-center text-red-500 transition-colors cursor-pointer"
title="删除"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1-1H8a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{item.text}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.stopPropagation();
const { deleteVoice } = useVoiceStore.getState();
deleteVoice(item.id)
.then(() => {
console.log('语音记录删除成功');
toast.success('删除成功', { autoClose: 200 });
})
.catch((error) => {
console.error('删除语音记录失败:', error);
toast.error('删除失败: ' + (error instanceof Error ? error.message : '未知错误'));
});
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<button
onClick={(e) => {
e.stopPropagation();
const { deleteVoice } = useVoiceStore.getState();
deleteVoice(item.id)
.then(() => {
console.log('语音记录删除成功');
toast.success('删除成功', { autoClose: 200 });
})
.catch((error) => {
console.error('删除语音记录失败:', error);
toast.error('删除失败: ' + (error instanceof Error ? error.message : '未知错误'));
});
}}
className="w-5 h-5 hover:bg-red-100 rounded flex items-center justify-center text-red-500 transition-colors cursor-pointer"
title="删除"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1-1H8a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
</div>
</div>
{item.text && (
<div
@@ -230,7 +356,15 @@ export const VadVoice = () => {
setError: setStoreError
} = useVoiceStore();
const [listen, setListen] = useState<boolean>(false);
// 使用设置 store
const {
openModal: openSettingModal,
listen,
setListen,
autoRecognize,
setAutoRecognize
} = useSettingStore();
const [vadStatus, setVadStatus] = useState<'idle' | 'initializing' | 'ready' | 'error'>('idle');
const [realListen, setRealListen] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
@@ -238,7 +372,8 @@ export const VadVoice = () => {
const ref = useRef<MicVAD | null>(null);
const initializingRef = useRef<boolean>(false);
async function initializeVAD() {
async function initializeVAD(ls: boolean = true) {
if (!ls) { return }
if (ref.current || initializingRef.current) return;
initializingRef.current = true;
@@ -247,7 +382,6 @@ export const VadVoice = () => {
try {
console.log('Starting VAD initialization...');
// 添加延迟确保资源加载完成
await new Promise((resolve) => setTimeout(resolve, 500));
@@ -310,7 +444,7 @@ export const VadVoice = () => {
const handleUserInteraction = async () => {
if (!userInteracted) {
setUserInteracted(true);
await initializeVAD();
// await initializeVAD();
}
};
@@ -318,7 +452,6 @@ export const VadVoice = () => {
useEffect(() => {
initializeStore();
}, [initializeStore]);
useEffect(() => {
// 页面加载时不自动初始化,等待用户交互
const handleFirstClick = () => {
@@ -329,16 +462,23 @@ export const VadVoice = () => {
};
document.addEventListener('click', handleFirstClick);
// handleUserInteraction()
return () => {
document.removeEventListener('click', handleFirstClick);
// 清理 VAD 资源
if (ref.current) {
ref.current.destroy();
ref.current = null;
}
};
}, [])
useEffect(() => {
if (!userInteracted) {
return
}
console.log('VadVoice listen changed:', listen, userInteracted);
initializeVAD(listen);
return () => {
ref.current?.destroy?.();
ref.current = null;
setVadStatus('idle');
}
}, [listen, userInteracted]);
const close = () => {
if (ref.current) {
ref.current.destroy();
@@ -369,20 +509,26 @@ export const VadVoice = () => {
return <div className="h-full flex flex-col">
{/* Audio Recordings List */}
<div className="flex-1 overflow-y-auto px-2 py-3 min-h-0 max-h-200">
{!userInteracted && vadStatus === 'idle' ? (
<div className="text-center text-gray-400 text-sm py-8">
{
voiceList.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-8">
<div className="mb-2">🎤</div>
<div>No recordings yet</div>
<div className="text-xs mt-1">Start talking to record</div>
</div>
) : (
<ShowVoicePlayer data={voiceList} />
)
}
{!userInteracted && vadStatus === 'idle' && listen && (
<div className="text-center text-gray-400 text-sm py-8" onClick={() => {
setUserInteracted(true);
initializeVAD(listen)
}}>
<div className="mb-2">🎤</div>
<div>Click anywhere to initialize microphone</div>
<div className="text-xs mt-1">Browser requires user interaction for microphone access</div>
</div>
) : voiceList.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-8">
<div className="mb-2">🎤</div>
<div>No recordings yet</div>
<div className="text-xs mt-1">Start talking to record</div>
</div>
) : (
<ShowVoicePlayer data={voiceList} />
)}
</div>
@@ -401,7 +547,7 @@ export const VadVoice = () => {
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 rounded-full animate-pulse"></div>
)}
{realListen && (
{listen && realListen && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
)}
</div>
@@ -433,17 +579,41 @@ export const VadVoice = () => {
onClick={handleStartStop}
disabled={vadStatus === 'initializing'}
className={clsx(
"px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
"w-8 h-8 text-xs font-medium rounded-full flex items-center justify-center transition-colors cursor-pointer",
vadStatus === 'initializing' && "opacity-50 cursor-not-allowed",
listen
? "bg-red-100 text-red-700 hover:bg-red-200"
: "bg-green-100 text-green-700 hover:bg-green-200"
)}
>
{vadStatus === 'initializing' ? 'Initializing...' : (listen ? 'Stop' : 'Start')}
{vadStatus === 'initializing' ? <Loader className="w-4 h-4 inline-block animate-spin" /> : (listen ? <StopCircle className="w-4 h-4 inline-block" /> :
<Play className="w-4 h-4 inline-block" />
)}
</button>
<button onClick={() => {
const newStatus = !autoRecognize;
setAutoRecognize(newStatus);
}}
className={clsx(
"w-8 h-8 hover:bg-gray-200 rounded-full flex items-center justify-center text-gray-700 transition-colors cursor-pointer",
{ "bg-blue-200": autoRecognize }
)}
title={autoRecognize ? '自动转文字中' : '转文字禁用中'}>
<FileAudio className="w-4 h-4" />
</button>
<button
onClick={openSettingModal}
className="w-8 h-8 hover:bg-gray-200 rounded-full flex items-center justify-center text-gray-700 transition-colors cursor-pointer"
title="设置"
>
<Settings className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* 设置弹窗 */}
<SettingModal />
</div >
}

View File

@@ -10,7 +10,9 @@ export const getConfig = () => {
};
return {
// 火山引擎APPID
VOLCENGINE_AUC_APPID: getFromLocalStorage('VOLCENGINE_AUC_APPID', ''),
// 火山引擎Access Token
VOLCENGINE_AUC_TOKEN: getFromLocalStorage('VOLCENGINE_AUC_TOKEN', ''),
};
};

View File

@@ -0,0 +1,58 @@
@import 'tailwindcss';
.low-energy-spin {
animation: 2.5s linear 0s infinite normal forwards running spin;
}
/* 自定义滑块样式 */
.slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
.slider::-webkit-slider-track {
background: #e5e7eb;
height: 8px;
border-radius: 4px;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
background: #3b82f6;
height: 20px;
width: 20px;
border-radius: 50%;
cursor: pointer;
margin-top: -6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: background-color 0.2s;
}
.slider::-webkit-slider-thumb:hover {
background: #2563eb;
}
.slider::-moz-range-track {
background: #e5e7eb;
height: 8px;
border-radius: 4px;
border: none;
}
.slider::-moz-range-thumb {
background: #3b82f6;
height: 20px;
width: 20px;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: background-color 0.2s;
}
.slider::-moz-range-thumb:hover {
background: #2563eb;
}

View File

@@ -0,0 +1,133 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SettingState {
// 弹窗状态
isOpen: boolean;
mount: boolean;
// 语音设置
autoRecognize: boolean;
listen: boolean;
recognitionLanguage: string;
// 火山引擎配置
volcengineAucAppId: string;
volcengineAucToken: string;
// 操作方法
openModal: () => void;
closeModal: () => void;
setAutoRecognize: (value: boolean) => void;
setListen: (value: boolean) => void;
setRecognitionLanguage: (language: string) => void;
setVolcengineAucAppId: (appId: string) => void;
setVolcengineAucToken: (token: string) => void;
resetToDefault: () => void;
}
const defaultSettings = {
autoRecognize: false,
listen: false,
recognitionLanguage: 'zh-CN',
volcengineAucAppId: '',
volcengineAucToken: '',
};
// 从原有的 localStorage key 读取初始值
const getInitialVolcengineConfig = () => {
try {
return {
volcengineAucAppId: localStorage.getItem('VOLCENGINE_AUC_APPID') || '',
volcengineAucToken: localStorage.getItem('VOLCENGINE_AUC_TOKEN') || '',
};
} catch (error) {
console.warn('Failed to read volcengine config from localStorage:', error);
return {
volcengineAucAppId: '',
volcengineAucToken: '',
};
}
};
export const useSettingStore = create<SettingState>()(
persist(
(set, get) => ({
// 初始状态 - 合并默认设置和从 localStorage 读取的火山引擎配置
isOpen: false,
...defaultSettings,
...getInitialVolcengineConfig(),
mount: false,
// 弹窗控制方法
openModal: () => set({ isOpen: true }),
closeModal: () => set({ isOpen: false }),
// 设置更新方法
setAutoRecognize: (value: boolean) => set({ autoRecognize: value }),
setListen: (value: boolean) => set({ listen: value }),
setRecognitionLanguage: (language: string) => set({ recognitionLanguage: language }),
setVolcengineAucAppId: (appId: string) => {
// 同时更新 zustand 状态和原有的 localStorage key
try {
localStorage.setItem('VOLCENGINE_AUC_APPID', appId);
} catch (error) {
console.warn('Failed to save VOLCENGINE_AUC_APPID to localStorage:', error);
}
set({ volcengineAucAppId: appId });
},
setVolcengineAucToken: (token: string) => {
// 同时更新 zustand 状态和原有的 localStorage key
try {
localStorage.setItem('VOLCENGINE_AUC_TOKEN', token);
} catch (error) {
console.warn('Failed to save VOLCENGINE_AUC_TOKEN to localStorage:', error);
}
set({ volcengineAucToken: token });
},
// 重置为默认设置
resetToDefault: () => {
try {
localStorage.removeItem('VOLCENGINE_AUC_APPID');
localStorage.removeItem('VOLCENGINE_AUC_TOKEN');
} catch (error) {
console.warn('Failed to remove volcengine config from localStorage:', error);
}
set({
...defaultSettings,
volcengineAucAppId: '',
volcengineAucToken: '',
});
},
}),
{
name: 'voice-settings',
partialize: (state) => ({
autoRecognize: state.autoRecognize,
listen: state.listen,
recognitionLanguage: state.recognitionLanguage,
mount: true,
// 火山引擎配置不通过 zustand persist 保存,而是直接使用原有的 localStorage key
}),
}
)
);
// 兼容原有 config.ts 的 API
export const getConfig = () => {
const state = useSettingStore.getState();
return {
VOLCENGINE_AUC_APPID: state.volcengineAucAppId,
VOLCENGINE_AUC_TOKEN: state.volcengineAucToken,
};
};
export const setConfig = (config: { VOLCENGINE_AUC_APPID?: string; VOLCENGINE_AUC_TOKEN?: string }) => {
const { setVolcengineAucAppId, setVolcengineAucToken } = useSettingStore.getState();
if (config.VOLCENGINE_AUC_APPID !== undefined) {
setVolcengineAucAppId(config.VOLCENGINE_AUC_APPID);
}
if (config.VOLCENGINE_AUC_TOKEN !== undefined) {
setVolcengineAucToken(config.VOLCENGINE_AUC_TOKEN);
}
};

View File

@@ -3,6 +3,7 @@ import { devtools, persist } from 'zustand/middleware';
import { Speak, getDayOfYear, CreateSpeakData } from '../modules/speak-db/speak';
import { speakService } from '../modules/speak-db/speak-service';
import { getText } from '../modules/text';
import { useSettingStore } from './settingStore';
interface VoiceState {
// 状态数据
@@ -10,7 +11,7 @@ interface VoiceState {
isLoading: boolean;
error: string | null;
currentDay: number;
// 动作方法
initialize: () => Promise<void>;
addVoice: (url: string, duration: number, audioBlob?: Blob) => Promise<Speak>;
@@ -45,14 +46,14 @@ const base64ToUrl = (base64: string, mimeType: string = 'audio/wav'): string =>
if (base64.startsWith('blob:')) {
return base64;
}
// 将 base64 转换为 ArrayBuffer
const binaryString = window.atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// 创建 Blob 和 URL
const blob = new Blob([bytes], { type: mimeType });
return URL.createObjectURL(blob);
@@ -74,26 +75,26 @@ export const useVoiceStore = create<VoiceState>()(
// 初始化:从 IndexedDB 获取当天的记录
initialize: async () => {
const { setLoading, setError, generateAudioUrls } = get();
try {
setLoading(true);
setError(null);
// 初始化 speak service
await speakService.init();
// 获取当天的语音记录
const currentDay = getDayOfYear();
const todayVoices = await speakService.getSpeaksByDay(currentDay);
set({
set({
voiceList: todayVoices,
currentDay: currentDay
});
// 为获取到的记录生成 audio URLs
await generateAudioUrls();
} catch (error) {
console.error('初始化语音列表失败:', error);
setError(error instanceof Error ? error.message : '初始化失败');
@@ -105,17 +106,17 @@ export const useVoiceStore = create<VoiceState>()(
// 添加新的语音记录
addVoice: async (url: string, duration: number, audioBlob?: Blob) => {
const { setError } = get();
const autoRecognize = useSettingStore.getState().autoRecognize;
try {
setError(null);
let fileData: string | undefined;
// 如果提供了 audioBlob将其转换为 base64 保存到 IndexedDB
if (audioBlob) {
fileData = await blobToBase64(audioBlob);
}
// 创建语音记录(不保存 url只保存 base64 数据)
const speakData = {
duration: Math.ceil(duration),
@@ -123,24 +124,28 @@ export const useVoiceStore = create<VoiceState>()(
day: getDayOfYear(),
no: 0, // 将由 service 自动生成
timestamp: Date.now(),
type: 'normal' as const
type: 'normal' as const,
text: '', // 初始为空
};
if (autoRecognize) {
speakData.text = await getText(fileData || '').then(res => res.text);
}
// 保存到 IndexedDB不包含 url
const newSpeak = await speakService.createSpeakAuto(speakData);
// 为新记录生成 URL 并添加到状态
const speakWithUrl = {
...newSpeak,
url: newSpeak.file ? base64ToUrl(newSpeak.file) : url
};
set(state => ({
voiceList: [...state.voiceList, speakWithUrl]
}));
return speakWithUrl;
} catch (error) {
console.error('添加语音记录失败:', error);
setError(error instanceof Error ? error.message : '添加失败');
@@ -151,16 +156,16 @@ export const useVoiceStore = create<VoiceState>()(
// 更新语音记录
updateVoice: async (id: string, updates: Partial<Speak>) => {
const { setError } = get();
try {
setError(null);
// 从更新数据中移除 url因为 url 不应该保存到 IndexedDB
const { url, ...updatesWithoutUrl } = updates;
// 更新 IndexedDB 中的记录
const updatedSpeak = await speakService.updateSpeak(id, updatesWithoutUrl);
// 更新状态中的记录
set(state => ({
voiceList: state.voiceList.map(voice => {
@@ -175,7 +180,7 @@ export const useVoiceStore = create<VoiceState>()(
return voice;
})
}));
} catch (error) {
console.error('更新语音记录失败:', error);
setError(error instanceof Error ? error.message : '更新失败');
@@ -186,25 +191,25 @@ export const useVoiceStore = create<VoiceState>()(
// 删除语音记录
deleteVoice: async (id: string) => {
const { setError } = get();
try {
setError(null);
// 从 IndexedDB 删除
await speakService.deleteSpeak(id);
// 从状态中移除并释放 URL
set(state => {
const voiceToDelete = state.voiceList.find(voice => voice.id === id);
if (voiceToDelete && voiceToDelete.url && voiceToDelete.url.startsWith('blob:')) {
URL.revokeObjectURL(voiceToDelete.url);
}
return {
voiceList: state.voiceList.filter(voice => voice.id !== id)
};
});
} catch (error) {
console.error('删除语音记录失败:', error);
setError(error instanceof Error ? error.message : '删除失败');
@@ -215,36 +220,36 @@ export const useVoiceStore = create<VoiceState>()(
// 识别语音记录
recognizeVoice: async (id: string) => {
const { setError } = get();
try {
setError(null);
// 获取语音记录
const voice = get().voiceList.find(v => v.id === id);
if (!voice || !voice.file) {
throw new Error('找不到语音记录或音频数据');
}
// 调用语音识别API
const result = await getText(voice.file);
const recognizedText = result.text;
if (!recognizedText) {
throw new Error('语音识别失败,未能获取到文字内容');
}
// 更新数据库中的记录
await speakService.updateSpeak(id, { text: recognizedText });
// 更新状态中的记录
set(state => ({
voiceList: state.voiceList.map(voice =>
voiceList: state.voiceList.map(voice =>
voice.id === id ? { ...voice, text: recognizedText } : voice
)
}));
return recognizedText;
} catch (error) {
console.error('语音识别失败:', error);
setError(error instanceof Error ? error.message : '语音识别失败');
@@ -255,13 +260,13 @@ export const useVoiceStore = create<VoiceState>()(
// 清空今天的语音记录
clearTodayVoices: async () => {
const { setError, currentDay } = get();
try {
setError(null);
// 从 IndexedDB 清空今天的记录
await speakService.deleteSpeaksByDay(currentDay);
// 清空状态并释放所有 URL
set(state => {
state.voiceList.forEach(voice => {
@@ -269,10 +274,10 @@ export const useVoiceStore = create<VoiceState>()(
URL.revokeObjectURL(voice.url);
}
});
return { voiceList: [] };
});
} catch (error) {
console.error('清空今天语音记录失败:', error);
setError(error instanceof Error ? error.message : '清空失败');
@@ -283,14 +288,14 @@ export const useVoiceStore = create<VoiceState>()(
// 为所有记录生成音频 URL
generateAudioUrls: async () => {
const { voiceList } = get();
set(state => ({
voiceList: state.voiceList.map(voice => {
// 如果已经有 URL 且是 blob URL跳过
if (voice.url && voice.url.startsWith('blob:')) {
return voice;
}
// 如果有 file 数据,从 base64 生成 URL
if (voice.file) {
return {
@@ -298,7 +303,7 @@ export const useVoiceStore = create<VoiceState>()(
url: base64ToUrl(voice.file)
};
}
return voice;
})
}));

View File

@@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}