ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: preflight и запись audio для Voice Tasker
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Mic, RotateCcw, Square, Upload, X } from "lucide-react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// services
|
||||
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
||||
|
||||
const workspaceAIService = new WorkspaceAIService();
|
||||
|
||||
type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "error";
|
||||
|
||||
const UNAVAILABLE_LABELS = {
|
||||
disabled: "AI-функции не активированы для этого workspace",
|
||||
missing_api_key: "OpenAI key не сохранен для этого workspace",
|
||||
not_configured: "AI-функции не настроены для этого workspace",
|
||||
role_denied: "Voice Task недоступен для вашей роли",
|
||||
} as const;
|
||||
|
||||
function getSupportedMimeType() {
|
||||
if (typeof MediaRecorder === "undefined") return "";
|
||||
|
||||
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"];
|
||||
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "";
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const roundedSeconds = Math.max(0, Math.floor(seconds));
|
||||
const minutes = Math.floor(roundedSeconds / 60);
|
||||
const remainingSeconds = roundedSeconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [status, setStatus] = useState<TVoiceTaskerStatus>("idle");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startedAtRef = useRef(0);
|
||||
|
||||
const { data: preflight } = useSWR(
|
||||
workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => workspaceAIService.retrieveVoiceTaskPreflight(workspaceSlug) : null,
|
||||
{ refreshInterval: 30000 }
|
||||
);
|
||||
|
||||
const maxDuration = preflight?.max_audio_duration_seconds ?? 120;
|
||||
const isAvailable = !!preflight?.available;
|
||||
const isRecording = status === "recording";
|
||||
const isUploading = status === "uploading";
|
||||
|
||||
const tooltipContent = useMemo(() => {
|
||||
if (!preflight) return "Voice Task";
|
||||
if (preflight.available) return "Voice Task";
|
||||
return UNAVAILABLE_LABELS[preflight.reason ?? "not_configured"];
|
||||
}, [preflight]);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
clearTimer();
|
||||
|
||||
if (recorder && recorder.state === "recording") {
|
||||
recorder.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
stopStream();
|
||||
}, [clearTimer, stopStream]);
|
||||
|
||||
const resetRecording = useCallback(() => {
|
||||
stopRecording();
|
||||
setAudioBlob(null);
|
||||
setAudioUrl(null);
|
||||
setDuration(0);
|
||||
setError(null);
|
||||
setStatus("idle");
|
||||
}, [stopRecording]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetRecording();
|
||||
setIsOpen(false);
|
||||
}, [resetRecording]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimer();
|
||||
stopStream();
|
||||
},
|
||||
[clearTimer, stopStream]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioBlob) {
|
||||
setAudioUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(audioBlob);
|
||||
setAudioUrl(objectUrl);
|
||||
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
}, [audioBlob]);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
||||
setError("Браузер не поддерживает запись аудио.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resetRecording();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
});
|
||||
const mimeType = getSupportedMimeType();
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
|
||||
chunksRef.current = [];
|
||||
streamRef.current = stream;
|
||||
mediaRecorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const type = recorder.mimeType || mimeType || "audio/webm";
|
||||
setAudioBlob(new Blob(chunksRef.current, { type }));
|
||||
setStatus("idle");
|
||||
stopStream();
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
startedAtRef.current = Date.now();
|
||||
setDuration(0);
|
||||
setError(null);
|
||||
setStatus("recording");
|
||||
|
||||
timerRef.current = window.setInterval(() => {
|
||||
const elapsed = (Date.now() - startedAtRef.current) / 1000;
|
||||
setDuration(elapsed);
|
||||
if (elapsed >= maxDuration) stopRecording();
|
||||
}, 250);
|
||||
} catch {
|
||||
setError("Не удалось получить доступ к микрофону.");
|
||||
setStatus("error");
|
||||
stopStream();
|
||||
clearTimer();
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAudio = async () => {
|
||||
if (!audioBlob) return;
|
||||
|
||||
setStatus("uploading");
|
||||
setError(null);
|
||||
|
||||
const audioType = audioBlob.type || "audio/webm";
|
||||
const extension = audioType.includes("mp4") ? "m4a" : "webm";
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioBlob, `voice-task.${extension}`);
|
||||
formData.append("duration_seconds", String(Math.max(1, Math.ceil(duration))));
|
||||
formData.append(
|
||||
"client_context",
|
||||
JSON.stringify({
|
||||
current_page: window.location.pathname,
|
||||
locale: navigator.language,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await workspaceAIService.uploadVoiceTaskAudio(workspaceSlug, formData);
|
||||
setStatus("success");
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Аудио отправлено",
|
||||
message: "Backend принял запись. Распознавание будет подключено следующим этапом.",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио.";
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Voice Task не отправлен",
|
||||
message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none fixed right-4 z-[29] bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)]">
|
||||
<Tooltip tooltipContent={tooltipContent} position="left">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"pointer-events-auto flex size-11 items-center justify-center rounded-full border-[0.5px] shadow-lg transition",
|
||||
isAvailable
|
||||
? "border-pink-500/40 bg-pink-500 text-white hover:bg-pink-600"
|
||||
: "cursor-not-allowed border-subtle bg-layer-2 text-tertiary"
|
||||
)}
|
||||
disabled={!isAvailable}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Mic className="size-5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.MD}>
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-18 font-medium text-primary">Voice Task</h3>
|
||||
<p className="mt-1 text-13 text-secondary">Запись до {maxDuration} секунд</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-md text-tertiary hover:bg-layer-2 hover:text-primary"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border-[0.5px] border-subtle bg-layer-1 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div>
|
||||
<div className="mt-1 text-12 text-tertiary">
|
||||
{status === "success" ? "Audio uploaded" : isRecording ? "Recording" : "Ready"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-14 items-center justify-center rounded-full",
|
||||
isRecording ? "bg-red-500/15 text-red-500" : "bg-pink-500/10 text-pink-500"
|
||||
)}
|
||||
>
|
||||
<Mic className={cn("size-6", { "animate-pulse": isRecording })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{audioUrl && !isRecording && (
|
||||
<audio controls src={audioUrl} className="mt-4 w-full">
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-md border-[0.5px] border-red-500/30 bg-red-500/10 px-3 py-2 text-12 text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
||||
{audioBlob && !isRecording && (
|
||||
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading}>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Перезаписать
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={isRecording ? "error-fill" : "secondary"}
|
||||
size="lg"
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
|
||||
{isRecording ? "Стоп" : "Записать"}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" onClick={uploadAudio} loading={isUploading} disabled={!audioBlob || isRecording}>
|
||||
<Upload className="mr-2 size-4" />
|
||||
Отправить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user