This commit is contained in:
2025-12-24 11:21:07 +08:00
parent dd7e44a4b8
commit da249ad779
7 changed files with 308 additions and 64 deletions

148
pnpm-lock.yaml generated
View File

@@ -90,6 +90,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@ricky0123/vad-web':
specifier: ^0.0.30
version: 0.0.30
@@ -869,6 +872,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
@@ -944,6 +960,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
@@ -1001,6 +1030,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@@ -1046,6 +1088,40 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@ricky0123/vad-web@0.0.30':
resolution: {integrity: sha512-cJyYrh4YeeUBJcbR9Bic/bFDyB9qBkAepvpuWM3vLxnAi7bC3VHzf51UeNdT+OtY4D7MLAgV8iJMc4z41ZnaWg==}
@@ -4419,6 +4495,15 @@ snapshots:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.3)':
dependencies:
react: 19.2.3
@@ -4490,6 +4575,24 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/rect': 1.1.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -4533,6 +4636,26 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.2.3)':
dependencies:
react: 19.2.3
@@ -4567,6 +4690,31 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-use-rect@1.1.1(@types/react@19.2.7)(react@19.2.3)':
dependencies:
'@radix-ui/rect': 1.1.1
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-use-size@1.1.1(@types/react@19.2.7)(react@19.2.3)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/rect@1.1.1': {}
'@ricky0123/vad-web@0.0.30':
dependencies:
onnxruntime-web: 1.23.0

View File

@@ -30,6 +30,7 @@
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@ricky0123/vad-web": "^0.0.30",
"@szhsin/react-menu": "^4.5.1",
"@tailwindcss/vite": "^4.1.18",

View File

@@ -35,7 +35,9 @@ export class AudioRecorder {
public onAudioData(callback: AudioDataCallback): void {
this.onAudioDataCallback = callback;
}
getMediaStream(): MediaStream | null {
return this.mediaStream;
}
/**
* 开始录制
*/
@@ -47,17 +49,17 @@ export class AudioRecorder {
try {
// 获取麦克风权限
this.mediaStream = await navigator.mediaDevices.getUserMedia({
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
}
});
// 创建音频上下文
this.audioContext = new AudioContext({
sampleRate: this.config.sampleRate
this.audioContext = new AudioContext({
sampleRate: this.config.sampleRate
});
// 加载AudioWorklet处理器
@@ -68,7 +70,7 @@ export class AudioRecorder {
// 创建AudioWorklet节点
this.workletNode = new AudioWorkletNode(
this.audioContext,
this.audioContext,
'audio-recorder-processor',
{
processorOptions: {
@@ -228,7 +230,7 @@ export class AudioRecorder {
for (let i = 0; i < binary.length; i++) {
binaryString += String.fromCharCode(binary[i]);
}
return typeof window !== 'undefined' && window.btoa
return typeof window !== 'undefined' && window.btoa
? window.btoa(binaryString)
: Buffer.from(binaryString, 'binary').toString('base64');
}

View File

@@ -0,0 +1,40 @@
import {
Tooltip, TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Copy } from "lucide-react";
import { useMemo } from "react";
import { toast } from "react-toastify";
export const ShowText = (props: { text: string, icon?: any, title?: string }) => {
const title = props.title || '';
const icon = props.icon || '🕘';
return (
<div className="flex relative w-full overflow-hidden">
<Tooltip >
<TooltipTrigger className="text-left text-xs text-gray-400 mt-1 overflow-hidden text-ellipsis whitespace-nowrap">
{icon} {title ? `${title}:` : ''} {props.text}
</TooltipTrigger>
{props.text && <TooltipContent className="max-w-xs">
{props.text}
</TooltipContent>
}
</Tooltip>
<div className="cursor-pointer shrink-0" onClick={() => {
// copy
navigator.clipboard.writeText(props.text).then(() => {
toast.success('已复制', {
autoClose: 500,
position: 'top-center'
});
}).catch((error) => {
console.error('复制失败:', error);
toast.error('复制失败,请手动选择文字复制');
});
}}>
<Copy className='text-gray-400' />
</div>
</div>
)
}

View File

@@ -10,7 +10,7 @@ import { Speak } from "./speak-db/speak";
import { useVoiceStore } from "../store/voiceStore";
import { useSettingStore } from "../store/settingStore";
import { SettingModal } from "./SettingModal";
import { AudioRecorder } from "./AudioRecorder";
import {
AlertDialog,
AlertDialogAction,
@@ -22,6 +22,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "../../../../components/ui/alert-dialog";
import { ShowText } from "./ShowText";
type VadVoiceProps = {
data: Speak;
@@ -381,6 +382,7 @@ export const VadVoice = () => {
const [errorMessage, setErrorMessage] = useState<string>('');
const [userInteracted, setUserInteracted] = useState<boolean>(false);
const ref = useRef<MicVAD | null>(null);
const audioRecorderRef = useRef<AudioRecorder | null>(null);
const initializingRef = useRef<boolean>(false);
async function initializeVAD(ls: boolean = true) {
@@ -397,11 +399,26 @@ export const VadVoice = () => {
await new Promise((resolve) => setTimeout(resolve, 500));
const myvad = await MicVAD.new({
getStream: async () => {
const audioRecorder = audioRecorderRef.current || new AudioRecorder({
sampleRate: 16000,
bufferSize: 4096,
});
await audioRecorder.start();
// 设置音频数据回调
audioRecorder.onAudioData((audioData) => {
const base64 = AudioRecorder.float32ArrayToBase64(audioData);
const relatime = useVoiceStore.getState().relatime;
relatime?.sendBase64(base64, { isRelatime: true });
});
audioRecorderRef.current = audioRecorder;
return audioRecorder.getMediaStream()!;
},
onSpeechEnd: async (audio) => {
try {
const wavBuffer = utils.encodeWAV(audio)
const relatime = useVoiceStore.getState().relatime;
relatime?.sendBase64?.(utils.arrayBufferToBase64(wavBuffer));
relatime?.sendBase64?.(utils.arrayBufferToBase64(wavBuffer), { isRelatime: false });
const audioBlob = new Blob([wavBuffer], { type: 'audio/wav' })
const tempUrl = URL.createObjectURL(audioBlob)
@@ -474,8 +491,13 @@ export const VadVoice = () => {
document.removeEventListener('click', handleFirstClick);
}
};
document.addEventListener('click', handleFirstClick);
// 如果是tauri的环境不需要用户交互
// @ts-ignore
if (window.__TAURI_INTERNALS__) {
handleUserInteraction();
} else {
document.addEventListener('click', handleFirstClick);
}
return () => {
document.removeEventListener('click', handleFirstClick);
};
@@ -499,6 +521,9 @@ export const VadVoice = () => {
ref.current = null;
setListen(false);
setVadStatus('idle');
audioRecorderRef.current?.stop();
audioRecorderRef.current = null;
console.log('VAD closed');
}
}
@@ -547,10 +572,10 @@ export const VadVoice = () => {
</div>
{/* Voice Control Bottom Section */}
<div className="border-t border-gray-200 p-3 bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="relative">
<div className="border-t border-gray-200 p-3 bg-gray-50 w-full overflow-hidden">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center space-x-3 min-w-0 flex-1">
<div className="relative shrink-0">
<div className={clsx(
"h-8 w-8 rounded-lg bg-gradient-to-l from-[#7928CA] to-[#008080] flex items-center justify-center",
{ "animate-pulse": listen, "low-energy-spin": listen }
@@ -565,7 +590,7 @@ export const VadVoice = () => {
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
)}
</div>
<div>
<div className="w-[90%]">
<div className="text-sm font-medium text-gray-900">
{vadStatus === 'initializing' || storeLoading ? 'Initializing...' :
vadStatus === 'error' || storeError ? 'Error' :
@@ -578,53 +603,13 @@ export const VadVoice = () => {
`${voiceList.length} recording${voiceList.length !== 1 ? 's' : ''}`
)}
</div>
<div className=" ">
{lastRecognizedText && (
<div className="flex">
<div className="text-xs text-gray-400 mt-1 truncate">
🕘 : {lastRecognizedText}
</div>
<div className="cursor-pointer " onClick={() => {
// copy
navigator.clipboard.writeText(lastRecognizedText).then(() => {
toast.success('已复制', {
autoClose: 500,
position: 'top-center'
});
}).catch((error) => {
console.error('复制失败:', error);
toast.error('复制失败,请手动选择文字复制');
});
}}>
<Copy className='text-gray-400' />
</div>
</div>
)}
{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 className="w-full">
{lastRecognizedText && <ShowText text={lastRecognizedText} title="上次识别" icon={'🕘'} />}
<ShowText text={showText} icon={'📝'} />
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 shrink-0">
{vadStatus === 'error' && (
<button
onClick={retryInitialization}
@@ -637,7 +622,7 @@ export const VadVoice = () => {
onClick={handleStartStop}
disabled={vadStatus === 'initializing'}
className={clsx(
"w-8 h-8 text-xs font-medium rounded-full flex items-center justify-center transition-colors cursor-pointer",
"md:flex w-8 h-8 text-xs font-medium rounded-full 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"
@@ -653,7 +638,7 @@ export const VadVoice = () => {
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",
"hidden md:flex w-8 h-8 hover:bg-gray-200 rounded-full items-center justify-center text-gray-700 transition-colors cursor-pointer",
{ "bg-blue-200": autoRecognize }
)}

View File

@@ -6,6 +6,7 @@ export class Relatime {
ready = false
timeoutHandle: NodeJS.Timeout | null = null
startTime: number = 0
isRelatime: boolean = true
constructor() {
// const url = new URL('/ws/asr', "http://localhost:51015")
const url = new URL('/ws/asr', window.location.origin)
@@ -50,9 +51,17 @@ export class Relatime {
const voice = data.toString('base64');
this.asr.ws.send(JSON.stringify({ voice }));
}
sendBase64(data: string, opts?: { isRelatime?: boolean }) {
setIsRelatime(isRelatime: boolean) {
this.isRelatime = isRelatime;
}
async sendBase64(data: string, opts?: { isRelatime?: boolean }) {
if (!this.ready) return;
console.log('send 花费时间:', Date.now() - this.startTime);
if (opts?.isRelatime !== this.isRelatime) {
return;
}
// console.log('send 花费时间:', Date.now() - this.startTime);
const connected = await this.asr.checkConnected();
if (!connected) return;
this.asr.ws.send(JSON.stringify({ voice: data, format: 'float32', time: Date.now(), ...opts }));
// if (this.timeoutHandle) {
// clearTimeout(this.timeoutHandle);

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }