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
+55
View File
@@ -412,6 +412,61 @@ describe("NanobotClient", () => {
);
});
it("sends transcription requests and resolves transcription results outside chat dispatch", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onChat("chat-a", handler);
client.connect();
lastSocket().fakeOpen();
const promise = client.transcribeAudio("data:audio/webm;base64,AAAA", {
durationMs: 1234,
timeoutMs: 1_000,
});
const frame = JSON.parse(lastSocket().sent.at(-1) as string);
expect(frame).toMatchObject({
type: "transcribe_audio",
data_url: "data:audio/webm;base64,AAAA",
duration_ms: 1234,
});
expect(typeof frame.request_id).toBe("string");
lastSocket().fakeMessage({
event: "transcription_result",
request_id: frame.request_id,
text: "hello from voice",
});
await expect(promise).resolves.toBe("hello from voice");
expect(handler).not.toHaveBeenCalled();
});
it("rejects pending transcription requests on server errors and socket close", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const errored = client.transcribeAudio("data:audio/webm;base64,AAAA", { timeoutMs: 1_000 });
const errorFrame = JSON.parse(lastSocket().sent.at(-1) as string);
lastSocket().fakeMessage({
event: "transcription_error",
request_id: errorFrame.request_id,
detail: "not_configured",
});
await expect(errored).rejects.toThrow("not_configured");
const dropped = client.transcribeAudio("data:audio/webm;base64,BBBB", { timeoutMs: 1_000 });
lastSocket().close();
await expect(dropped).rejects.toThrow("socket closed");
});
it("queues sends while connecting and flushes on open", () => {
const client = new NanobotClient({
url: "ws://test",