mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 22:08:38 +03:00
fix(webui): explain HTTPS requirement for voice input (#5304)
This commit is contained in:
@@ -288,6 +288,13 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||
form.
|
||||
|
||||
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
|
||||
capture only in secure contexts. Voice input works on same-machine localhost;
|
||||
from another device, serve the WebUI over HTTPS with a certificate that device
|
||||
trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
|
||||
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
|
||||
reverse proxy and use that proxy's HTTPS URL.
|
||||
|
||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||
install missing nanobot support packages, such as adding a channel dependency,
|
||||
are blocked by default. To let trusted remote administrators change the Python
|
||||
@@ -322,6 +329,10 @@ If the page does not open, check these in order:
|
||||
4. You are opening port `8765`, not the gateway health port.
|
||||
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
||||
|
||||
If voice input asks for a secure connection, use HTTPS with a certificate the
|
||||
device trusts. Browsers do not expose microphone capture to
|
||||
`http://<your-ip>` origins.
|
||||
|
||||
For detailed diagnostics, see
|
||||
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
||||
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
||||
|
||||
@@ -2383,7 +2383,7 @@ export function ThreadComposer({
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"mx-3 mb-1 max-h-10 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
|
||||
"mx-3 mb-1 max-h-24 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
|
||||
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
|
||||
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ const VOICE_MIME_CANDIDATES = [
|
||||
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
|
||||
export type VoiceRecorderErrorKey =
|
||||
| "failed"
|
||||
| "insecureContext"
|
||||
| "noDevice"
|
||||
| "notConfigured"
|
||||
| "permission"
|
||||
@@ -163,6 +164,10 @@ export function useVoiceRecorder({
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
|
||||
onClearError();
|
||||
if (window.isSecureContext === false) {
|
||||
onError("insecureContext");
|
||||
return;
|
||||
}
|
||||
const mediaDevices = navigator.mediaDevices;
|
||||
const MediaRecorderCtor = mediaRecorderConstructor();
|
||||
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
|
||||
|
||||
@@ -1147,6 +1147,7 @@
|
||||
"recordingStatus": "Recording {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Chrome and other browsers block microphone access on remote HTTP pages for security. Open this WebUI over HTTPS to use voice input.",
|
||||
"unsupported": "Voice input is not supported in this browser.",
|
||||
"permission": "Allow microphone access in the address bar, then retry.",
|
||||
"notConfigured": "Configure a transcription provider first.",
|
||||
|
||||
@@ -1134,6 +1134,7 @@
|
||||
"recordingStatus": "Grabando {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Por seguridad, Chrome y otros navegadores bloquean el acceso al micrófono en páginas HTTP remotas. Abre esta WebUI mediante HTTPS para usar la entrada de voz.",
|
||||
"unsupported": "Este navegador no admite entrada de voz.",
|
||||
"permission": "Permite el micrófono en la barra de direcciones y vuelve a intentarlo.",
|
||||
"notConfigured": "Configura primero un proveedor de transcripción.",
|
||||
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"recordingStatus": "Enregistrement {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Pour des raisons de sécurité, Chrome et d’autres navigateurs bloquent l’accès au microphone sur les pages HTTP distantes. Ouvrez cette WebUI en HTTPS pour utiliser la saisie vocale.",
|
||||
"unsupported": "La saisie vocale n'est pas prise en charge par ce navigateur.",
|
||||
"permission": "Autorisez le microphone dans la barre d'adresse, puis réessayez.",
|
||||
"notConfigured": "Configurez d'abord un fournisseur de transcription.",
|
||||
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"recordingStatus": "Merekam {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Demi keamanan, Chrome dan browser lain memblokir akses mikrofon pada halaman HTTP jarak jauh. Buka WebUI ini melalui HTTPS untuk menggunakan input suara.",
|
||||
"unsupported": "Input suara tidak didukung di browser ini.",
|
||||
"permission": "Izinkan mikrofon di bilah alamat, lalu coba lagi.",
|
||||
"notConfigured": "Konfigurasikan penyedia transkripsi terlebih dahulu.",
|
||||
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"recordingStatus": "録音中 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "セキュリティ上、Chrome などのブラウザーはリモートの HTTP ページからのマイクアクセスをブロックします。音声入力を使用するには、この WebUI を HTTPS で開いてください。",
|
||||
"unsupported": "このブラウザーは音声入力に対応していません。",
|
||||
"permission": "アドレスバーでマイクを許可してから再試行してください。",
|
||||
"notConfigured": "先に文字起こしプロバイダーを設定してください。",
|
||||
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"recordingStatus": "녹음 중 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "보안을 위해 Chrome 및 기타 브라우저는 원격 HTTP 페이지의 마이크 접근을 차단합니다. 음성 입력을 사용하려면 HTTPS로 이 WebUI를 여세요.",
|
||||
"unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.",
|
||||
"permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.",
|
||||
"notConfigured": "먼저 음성 변환 제공업체를 설정하세요.",
|
||||
|
||||
@@ -1147,6 +1147,7 @@
|
||||
"recordingStatus": "Gravando {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Por segurança, o Chrome e outros navegadores bloqueiam o acesso ao microfone em páginas HTTP remotas. Abra esta WebUI por HTTPS para usar a entrada de voz.",
|
||||
"unsupported": "A entrada de voz não é compatível com este navegador.",
|
||||
"permission": "Permita o microfone na barra de endereço e tente novamente.",
|
||||
"notConfigured": "Configure primeiro um provedor de transcrição.",
|
||||
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"recordingStatus": "Đang ghi {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "Vì lý do bảo mật, Chrome và các trình duyệt khác chặn quyền truy cập micrô trên các trang HTTP từ xa. Mở WebUI này qua HTTPS để dùng tính năng nhập bằng giọng nói.",
|
||||
"unsupported": "Trình duyệt này không hỗ trợ nhập bằng giọng nói.",
|
||||
"permission": "Cho phép micrô trong thanh địa chỉ rồi thử lại.",
|
||||
"notConfigured": "Hãy cấu hình nhà cung cấp chép lời trước.",
|
||||
|
||||
@@ -1146,6 +1146,7 @@
|
||||
"recordingStatus": "正在录音 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "出于安全考虑,Chrome 等浏览器会阻止远程 HTTP 页面访问麦克风。请通过 HTTPS 打开此 WebUI 以使用语音输入。",
|
||||
"unsupported": "当前浏览器不支持语音输入。",
|
||||
"permission": "请在地址栏允许麦克风后重试。",
|
||||
"notConfigured": "请先配置转写提供商。",
|
||||
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"recordingStatus": "正在錄音 {{time}}"
|
||||
},
|
||||
"voiceErrors": {
|
||||
"insecureContext": "基於安全考量,Chrome 等瀏覽器會阻止遠端 HTTP 頁面存取麥克風。請透過 HTTPS 開啟此 WebUI 以使用語音輸入。",
|
||||
"unsupported": "目前瀏覽器不支援語音輸入。",
|
||||
"permission": "請在網址列允許麥克風後重試。",
|
||||
"notConfigured": "請先設定轉寫供應商。",
|
||||
|
||||
@@ -651,6 +651,49 @@ describe("ThreadComposer", () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains the HTTPS requirement for voice input on an insecure origin", async () => {
|
||||
const { getUserMedia } = mockVoiceRecorder();
|
||||
vi.stubGlobal("isSecureContext", false);
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onTranscribeAudio={vi.fn(async () => "unused")}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Chrome and other browsers block microphone access on remote HTTP pages for security. "
|
||||
+ "Open this WebUI over HTTPS to use voice input.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveClass("max-h-24");
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the unsupported-browser error for secure origins without recording support", async () => {
|
||||
const { getUserMedia } = mockVoiceRecorder();
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
vi.stubGlobal("MediaRecorder", undefined);
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
onTranscribeAudio={vi.fn(async () => "unused")}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Voice input is not supported in this browser."),
|
||||
).toBeInTheDocument();
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("converts voice recordings to wav for Xiaomi MiMo transcription", async () => {
|
||||
mockVoiceRecorder(new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/webm" }));
|
||||
const { decodeAudioData } = mockVoiceAudioInput(
|
||||
|
||||
Reference in New Issue
Block a user