feat(transcription): add shared voice input support (#4232)

* feat(webui): add voice transcription input

* feat(webui): render ANSI output in code blocks

* refactor(webui): isolate voice recorder logic

* refactor(transcription): keep websocket ingress thin

* refactor(transcription): resolve channel audio settings on demand

* style(webui): neutralize voice waveform color

* feat(webui): add voice input tooltip

* feat(webui): add voice input keyboard shortcut

* fix(webui): distinguish voice shortcut platforms

* fix(webui): place voice button after model selector

* refactor(webui): share voice hold recording helpers

* fix(desktop): allow microphone voice input

* fix(webui): stabilize token usage month labels

* feat(webui): show voice input on settings overview

* fix(webui): label voice capability as recognition

* fix(webui): align capability overview status

* refactor(webui): isolate transcription socket handling

* fix(webui): soften silent voice waveform

* refactor(audio): clarify transcription service location

* docs(transcription): clarify audio and provider boundaries

* fix(exec): reduce session output polling flake
This commit is contained in:
Xubin Ren
2026-06-09 01:08:49 +08:00
committed by GitHub
parent 06d454a225
commit 9c81280300
49 changed files with 3071 additions and 257 deletions
+54
View File
@@ -15,6 +15,7 @@ import {
protocol,
session,
shell,
systemPreferences,
} from "electron";
import type { IpcMainInvokeEvent, WebContents } from "electron";
@@ -100,6 +101,58 @@ function isTrustedAppUrl(rawUrl: string): boolean {
}
}
function isTrustedPermissionRequest(
webContents: WebContents | null,
details: unknown,
): boolean {
return [
permissionDetail(details, "requestingUrl"),
permissionDetail(details, "securityOrigin"),
webContents?.getURL(),
].some((url) => typeof url === "string" && isTrustedAppUrl(url));
}
function permissionDetail(details: unknown, key: string): unknown {
return typeof details === "object" && details !== null
? (details as Record<string, unknown>)[key]
: undefined;
}
function isAudioOnlyMediaRequest(details: unknown): boolean {
const mediaTypes = permissionDetail(details, "mediaTypes");
if (Array.isArray(mediaTypes)) {
return mediaTypes.includes("audio") && !mediaTypes.includes("video");
}
return permissionDetail(details, "mediaType") === "audio";
}
async function requestNativeMicrophoneAccess(): Promise<boolean> {
if (process.platform !== "darwin") return true;
const status = systemPreferences.getMediaAccessStatus("microphone");
if (status === "granted") return true;
if (status === "denied" || status === "restricted") return false;
return await systemPreferences.askForMediaAccess("microphone");
}
function registerPermissionHandlers(): void {
session.defaultSession.setPermissionCheckHandler((webContents, permission, _origin, details) => (
permission === "media"
&& isTrustedPermissionRequest(webContents, details)
&& isAudioOnlyMediaRequest(details)
));
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
if (
permission !== "media"
|| !isTrustedPermissionRequest(webContents, details)
|| !isAudioOnlyMediaRequest(details)
) {
callback(false);
return;
}
void requestNativeMicrophoneAccess().then(callback, () => callback(false));
});
}
function assertTrustedIpc(event: IpcMainInvokeEvent): void {
const frameUrl = event.senderFrame?.url || event.sender.getURL();
if (!isTrustedAppUrl(frameUrl)) {
@@ -749,6 +802,7 @@ app.whenReady().then(async () => {
}
registerIpcHandlers();
registerPermissionHandlers();
registerAppProtocol(webDist, devUrl);
mainWindow = createWindow();