- A mic in the composer records, transcribes with the bot's transcription model (api/admin/bots/transcribe) and writes the text into the input at the cursor to edit before sending. Escape cancels; recordings aren't stored. - Every bot message has a read-aloud button that plays GET api/admin/bots/speak-message, streamed from the provider as the audio is made, so it starts in a couple of seconds. One message plays at a time; only the user's own sessions can be read. - Both buttons stay visible but disabled while the bot has no model for them (bot_voice page prop). - Transcription and speech no longer pick free models automatically: they're not available until a model is picked, and Settings says so. Transcription lists speech-to-text models only, so the chat-model transcript path and its missing-audio check are gone. Co-Authored-By: Claude Opus 5.5 <[email protected]>
51 lines
1.8 KiB
TypeScript
51 lines
1.8 KiB
TypeScript
import { useContext } from "react";
|
|
import Button from "@moduletrace/twui/layout/Button";
|
|
import { Square, Volume2 } from "lucide-react";
|
|
import { SpeakMessageContext } from "../(data)/speak-message-context";
|
|
|
|
const UnavailableTitle =
|
|
"Reading aloud needs a speech model. A super admin can pick one in Settings, under Models.";
|
|
|
|
type Props = {
|
|
message_id: number;
|
|
};
|
|
|
|
/**
|
|
* Reads a bot message aloud with its speech model, or stops it. Its title
|
|
* says why it last failed. Disabled, saying so, while speech has no model.
|
|
*/
|
|
export default function SpeakMessageButton({ message_id }: Props) {
|
|
const speak = useContext(SpeakMessageContext);
|
|
const active = speak.message_id == message_id;
|
|
const playing = active && speak.state == "playing";
|
|
const error = speak.error?.message_id == message_id ? speak.error.text : undefined;
|
|
|
|
return (
|
|
<Button
|
|
title={
|
|
!speak.enabled
|
|
? UnavailableTitle
|
|
: playing
|
|
? "Stop"
|
|
: error || "Read aloud"
|
|
}
|
|
aria-label={playing ? "Stop reading" : "Read aloud"}
|
|
aria-invalid={error ? true : undefined}
|
|
type="button"
|
|
disabled={!speak.enabled}
|
|
variant="ghost"
|
|
color="text"
|
|
loading={active && speak.state == "loading"}
|
|
loadingIconSize="smaller"
|
|
onClick={() => speak.toggle({ message_id })}
|
|
className="shrink-0 p-1! -mx-1 text-ink-secondary dark:text-ink-secondary hover:text-ink rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
|
|
>
|
|
{playing ? (
|
|
<Square size={12} aria-hidden fill="currentColor" />
|
|
) : (
|
|
<Volume2 size={14} aria-hidden />
|
|
)}
|
|
</Button>
|
|
);
|
|
}
|