Add dictation and streamed read-aloud to the chat

- 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]>
This commit is contained in:
2026-10-19 21:21:04 +01:00
co-authored by Claude Opus 5.5
parent 727cbf53c8
commit 163d9a434c
37 changed files with 936 additions and 197 deletions
@@ -0,0 +1,13 @@
import React from "react";
import type { SpeakMessage } from "../(hooks)/use-speak-message";
/**
* The conversation's read-aloud, shared by every bot message's play
* button.
*/
export const SpeakMessageContext = React.createContext<SpeakMessage>({
enabled: false,
message_id: null,
state: "idle",
toggle: () => {},
});
@@ -0,0 +1,37 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import fileInputToBase64 from "@moduletrace/twui/utils/form/fileInputToBase64";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { TranscribeVoiceRequest } from "@/src/schemas/bot-schema";
type Params = {
bot_id: number;
workspace_id?: number | null;
audio: Blob;
};
/**
* Sends a composer dictation to be turned into text with the bot's
* transcription model.
*/
export default async function transcribeVoice({
bot_id,
workspace_id,
audio,
}: Params): Promise<APIResponseObject<{ text: string }>> {
const ext = audio.type.split("/").pop() || "webm";
const { fileBase64Full } = await fileInputToBase64({
inputFile: new File([audio], `dictation.${ext}`, { type: audio.type }),
});
if (!fileBase64Full) {
return { success: false, msg: `Couldn't read the recording.` };
}
return await fetchApi<
TranscribeVoiceRequest,
APIResponseObject<{ text: string }>
>(`/api/admin/bots/transcribe`, {
method: "POST",
body: { bot_id, workspace_id, data_url: fileBase64Full },
});
}
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from "react";
export type SpeakMessageState = "idle" | "loading" | "playing";
export type SpeakMessage = {
/**
* The bot has a speech model here; the play buttons are disabled
* without one.
*/
enabled: boolean;
/**
* The message being fetched or played, if any.
*/
message_id: number | null;
state: SpeakMessageState;
error?: { message_id: number; text: string };
/**
* Plays the message, or stops it when it's the one playing. Starting
* one stops any other.
*/
toggle: (params: { message_id: number }) => void;
};
/**
* The conversation's read-aloud: one audio element for every message,
* pointed at `api/admin/bots/speak-message`, which streams, so playing
* starts before the whole message is spoken. Stopping drops the stream.
*/
export default function useSpeakMessage({ enabled }: { enabled: boolean }): SpeakMessage {
const [message_id, setMessageId] = useState<number | null>(null);
const [state, setState] = useState<SpeakMessageState>("idle");
const [error, setError] = useState<SpeakMessage["error"]>();
const audio_ref = useRef<HTMLAudioElement | null>(null);
const current_ref = useRef<number | null>(null);
useEffect(() => () => release(), []);
function release() {
const audio = audio_ref.current;
if (!audio) return;
audio.pause();
audio.removeAttribute("src");
audio.load();
}
function reset() {
current_ref.current = null;
setMessageId(null);
setState("idle");
}
function stop() {
release();
reset();
}
function play({ message_id }: { message_id: number }) {
stop();
setError(undefined);
current_ref.current = message_id;
setMessageId(message_id);
setState("loading");
const src = `/api/admin/bots/speak-message?message_id=${message_id}`;
const audio = audio_ref.current || new Audio();
const isCurrent = () => current_ref.current == message_id;
audio_ref.current = audio;
audio.onplaying = () => {
if (isCurrent()) setState("playing");
};
audio.onended = () => {
if (isCurrent()) reset();
};
audio.onerror = () => {
if (!isCurrent()) return;
reset();
explainError({ src }).then((text) => {
setError({ message_id, text });
});
};
audio.src = src;
audio.play().catch(() => {});
}
function toggle({ message_id: id }: { message_id: number }) {
if (current_ref.current == id) {
stop();
return;
}
play({ message_id: id });
}
return { enabled, message_id, state, error, toggle };
}
/**
* The player only says it failed, so ask again for the reason: a failure
* answers in JSON, and audio coming back instead is dropped at once.
*/
async function explainError({ src }: { src: string }) {
const fallback = "Couldn't read it aloud. Try again.";
const controller = new AbortController();
try {
const res = await fetch(src, { signal: controller.signal, cache: "no-store" });
if (!res.headers.get("content-type")?.includes("json")) {
controller.abort();
return fallback;
}
const json = await res.json();
return json?.msg || fallback;
} catch {
return fallback;
}
}
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import { AppData } from "@/src/data/app-data";
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
type Params = {
/**
* Gets the finished recording. The hook stays `transcribing` until it
* resolves.
*/
onRecorded: (params: { audio: Blob }) => Promise<void>;
};
/**
* Types the browser may record in, best first. Safari only has mp4.
*/
const RecordingTypes = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"];
/**
* Records the microphone for the composer's dictation. Stops on its own
* after `AppData.ComposerVoiceMaxMs`; Escape cancels. The mic is released whenever
* recording ends, and on unmount. `supported` is false without a secure
* context or `MediaRecorder`.
*/
export default function useVoiceRecorder({ onRecorded }: Params) {
const [state, setState] = useState<VoiceRecorderState>("idle");
const [elapsed_ms, setElapsedMs] = useState(0);
const [error, setError] = useState<string>();
const [supported, setSupported] = useState(false);
const recorder_ref = useRef<MediaRecorder | null>(null);
const cancelled_ref = useRef(false);
const on_recorded_ref = useRef(onRecorded);
on_recorded_ref.current = onRecorded;
useEffect(() => {
setSupported(
typeof MediaRecorder != "undefined" &&
Boolean(navigator.mediaDevices?.getUserMedia),
);
return () => {
cancelled_ref.current = true;
recorder_ref.current?.stop();
};
}, []);
useEffect(() => {
if (state != "recording") return;
const started_at = Date.now();
const timer = setInterval(() => {
const ms = Date.now() - started_at;
setElapsedMs(ms);
if (ms >= AppData.ComposerVoiceMaxMs) stop();
}, 250);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key != "Escape") return;
e.preventDefault();
cancel();
};
window.addEventListener("keydown", onKeyDown);
return () => {
clearInterval(timer);
window.removeEventListener("keydown", onKeyDown);
};
}, [state]);
async function start() {
if (state != "idle") return;
setError(undefined);
setElapsedMs(0);
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
setError("Microphone blocked. Allow it in the browser's site settings.");
return;
}
const type = RecordingTypes.find((t) => MediaRecorder.isTypeSupported(t));
const recorder = new MediaRecorder(stream, type ? { mimeType: type } : undefined);
const chunks: Blob[] = [];
cancelled_ref.current = false;
recorder.ondataavailable = (e) => {
if (e.data.size) chunks.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((track) => track.stop());
recorder_ref.current = null;
if (cancelled_ref.current || !chunks.length) {
setState("idle");
return;
}
setState("transcribing");
/**
* The bare type, without `;codecs=`, so it reads as a plain
* data URL.
*/
const audio = new Blob(chunks, {
type: (recorder.mimeType || type || "audio/webm").split(";")[0],
});
await on_recorded_ref.current({ audio }).catch(() => {});
setState("idle");
};
recorder_ref.current = recorder;
recorder.start();
setState("recording");
}
function stop() {
recorder_ref.current?.stop();
}
function cancel() {
cancelled_ref.current = true;
recorder_ref.current?.stop();
}
return { state, elapsed_ms, error, setError, supported, start, stop, cancel };
}
@@ -29,6 +29,7 @@ export default function BotMessageGroup({ bot, group }: Props) {
{group.messages.map((message) => (
<BotMessageLine
key={message.id}
message_id={message.id || null}
text={message.text || ""}
needs_you={Boolean(message.needs_you)}
/>
@@ -1,11 +1,14 @@
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import linkComputerRefs from "@/src/utils/link-computer-refs";
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
import NeedsYouChip from "./needs-you-chip";
import SpeakMessageButton from "./speak-message-button";
type Props = {
message_id?: number | null;
text: string;
needs_you?: boolean;
};
@@ -13,17 +16,30 @@ type Props = {
/**
* One bot message, rendered from markdown (GFM; raw HTML is never
* rendered). Computers it names (`computerRef`) show as buttons that
* switch the bot to them.
* switch the bot to them. A play button under it reads it aloud,
* disabled while the bot has no speech model.
*/
export default function BotMessageLine({ text, needs_you }: Props) {
export default function BotMessageLine({ message_id, text, needs_you }: Props) {
const can_speak = Boolean(message_id && text.trim());
return (
<Stack className="gap-1.5! items-start max-w-full">
<Stack className="w-full gap-2.5! items-start break-words">
<Markdown remarkPlugins={[remarkGfm]} components={MessageMarkdownComponents}>
<Markdown
remarkPlugins={[remarkGfm]}
components={MessageMarkdownComponents}
>
{linkComputerRefs({ text })}
</Markdown>
</Stack>
{needs_you ? <NeedsYouChip /> : null}
{needs_you || can_speak ? (
<Row className="gap-2! items-center">
{needs_you ? <NeedsYouChip /> : null}
{can_speak ? (
<SpeakMessageButton message_id={message_id!} />
) : null}
</Row>
) : null}
</Stack>
);
}
@@ -0,0 +1,75 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { Mic, Square } from "lucide-react";
import type { VoiceRecorderState } from "../(hooks)/use-voice-recorder";
const UnavailableTitle =
"Dictation needs a transcription model. A super admin can pick one in Settings, under Models.";
type Props = {
state: VoiceRecorderState;
elapsed_ms: number;
error?: string;
/**
* No transcription model for the bot here: shown disabled, saying so.
*/
unavailable?: boolean;
disabled?: boolean;
onStart: () => void;
onStop: () => void;
};
/**
* The composer's dictation button: a mic, a stop square with the time
* while recording, a spinner while the recording is turned into text.
* Its title says why it last failed, or that it needs a model.
*/
export default function ComposerVoiceButton({
state,
elapsed_ms,
error,
unavailable,
disabled,
onStart,
onStop,
}: Props) {
const recording = state == "recording";
const seconds = Math.floor(elapsed_ms / 1000);
return (
<Row className="shrink-0 gap-1.5! flex-nowrap items-center">
{recording ? (
<Span className="text-xs tabular-nums text-ink-secondary" aria-live="off">
{Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, "0")}
</Span>
) : null}
<Button
title={
unavailable
? UnavailableTitle
: recording
? "Stop and write it out (Esc cancels)"
: error || "Dictate"
}
aria-label={recording ? "Stop dictating" : "Dictate"}
aria-invalid={error ? true : undefined}
aria-pressed={recording}
type="button"
variant="ghost"
color="text"
disabled={disabled || unavailable}
loading={state == "transcribing"}
loadingIconSize="smaller"
onClick={recording ? onStop : onStart}
className="shrink-0 p-1! -mx-1 my-1 text-ink dark:text-ink rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
>
{recording ? (
<Square size={14} aria-hidden fill="currentColor" />
) : (
<Mic size={16} aria-hidden />
)}
</Button>
</Row>
);
}
@@ -2,20 +2,25 @@ import { useRef, useState, type KeyboardEvent } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Textarea from "@moduletrace/twui/form/Textarea";
import P from "@moduletrace/twui/layout/P";
import type {
BotExtensionsPayload,
BotMessageWithAttachments,
BotVoicePayload,
BotWithLastMessage,
} from "@/src/types";
import sendBotMessage from "../(functions)/send-bot-message";
import transcribeVoice from "../(functions)/transcribe-voice";
import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft";
import useAutoGrowTextarea from "../(hooks)/use-auto-grow-textarea";
import useVoiceRecorder from "../(hooks)/use-voice-recorder";
import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
import ComposerSendButton from "./composer-send-button";
import ComposerTools from "./composer-tools";
import ComposerVoiceButton from "./composer-voice-button";
type Props = {
bot: BotWithLastMessage;
@@ -27,6 +32,10 @@ type Props = {
* Its MCP servers and tools, listed under the input.
*/
extensions?: BotExtensionsPayload | null;
/**
* The dictation mic is disabled while transcription has no model.
*/
voice?: BotVoicePayload | null;
onSent: (params: { message: BotMessageWithAttachments }) => void;
};
@@ -36,9 +45,10 @@ type Props = {
* just above it. A click anywhere on the row focuses the input. Files can
* also be pasted or dropped in. Unsent text is kept per conversation
* across reloads, with its uploaded files. The bot's MCP servers and tools
* are listed under the input.
* are listed under the input. The mic dictates: what's said is written
* into the input at the cursor, to edit before sending.
*/
export default function Composer({ bot, workspace_id, extensions, onSent }: Props) {
export default function Composer({ bot, workspace_id, extensions, voice, onSent }: Props) {
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
@@ -48,12 +58,52 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
useAutoGrowTextarea({ ref: input_ref, value: text });
const recorder = useVoiceRecorder({
onRecorded: async ({ audio }) => {
if (!bot.id) return;
const res = await transcribeVoice({ bot_id: bot.id, workspace_id, audio });
if (!res.success || !res.singleRes?.text) {
recorder.setError(res.msg || "Couldn't write that out");
return;
}
insertText({ insert: res.singleRes.text });
},
});
const can_send =
Boolean(text.trim() || files.media_ids.length) &&
!sending &&
!files.uploading &&
recorder.state == "idle" &&
online;
/**
* Puts dictated text at the cursor (or the end), spaced from what's
* around it, and leaves the cursor after it.
*/
function insertText({ insert }: { insert: string }) {
const el = input_ref.current;
const start = el ? el.selectionStart : text.length;
const end = el ? el.selectionEnd : text.length;
const before = text.slice(0, start);
const after = text.slice(end);
const piece =
(before && !/\s$/.test(before) ? " " : "") +
insert +
(after && !/^\s/.test(after) ? " " : "");
const cursor = before.length + piece.length;
setText(before + piece + after);
requestAnimationFrame(() => {
input_ref.current?.focus();
input_ref.current?.setSelectionRange(cursor, cursor);
});
}
async function send() {
if (!can_send || !bot.id) return;
@@ -149,12 +199,28 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
}}
autoFocus
/>
{recorder.supported ? (
<ComposerVoiceButton
state={recorder.state}
elapsed_ms={recorder.elapsed_ms}
error={recorder.error}
unavailable={!voice?.transcription}
disabled={!online}
onStart={recorder.start}
onStop={recorder.stop}
/>
) : null}
<ComposerSendButton
disabled={!can_send}
loading={sending || files.uploading}
onClick={send}
/>
</Row>
{recorder.error ? (
<P role="alert" className="w-full max-w-container mx-auto px-3 md:px-6 -mt-5 pb-3 text-xs text-ink-secondary">
{recorder.error}
</P>
) : null}
<ComposerTools bot={bot} workspace_id={workspace_id} initial={extensions} />
</Stack>
</Stack>
@@ -0,0 +1,50 @@
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>
);
}
@@ -6,10 +6,12 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event";
import { BotComputersContext } from "../(data)/bot-computers-context";
import { SpeakMessageContext } from "../(data)/speak-message-context";
import useBotComputers from "../(hooks)/use-bot-computers";
import useTaskProgress from "../(hooks)/use-task-progress";
import useReplyProgress from "../(hooks)/use-reply-progress";
import useReplyCatchUp from "../(hooks)/use-reply-catch-up";
import useSpeakMessage from "../(hooks)/use-speak-message";
import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer";
@@ -21,7 +23,8 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
* bot's conversation, on its own or in a workspace (`pageProps.workspace`).
* The bot's computers are shared with the header's modal and the computer
* buttons in the thread, so a switch from either shows in both; either
* can open one in the viewer.
* can open one in the viewer. Bot messages can be read aloud, one at a
* time.
*/
export default function Conversation() {
const { pageProps } = useContext(AppContext);
@@ -47,6 +50,8 @@ export default function Conversation() {
computers: pageProps?.bot_computers || [],
});
const speak = useSpeakMessage({ enabled: Boolean(pageProps?.bot_voice?.speech) });
const progress = useTaskProgress({
bot_id: bot?.id || 0,
session_id,
@@ -112,40 +117,43 @@ export default function Conversation() {
return (
<BotComputersContext.Provider value={bot_computers}>
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader
bot={live_bot}
workspace={workspace}
has_other_unread={has_other_unread}
harnesses={pageProps?.bot_harness_overview?.harnesses}
/>
<MessageThread
bot={live_bot}
messages={messages}
progress={progress.text}
reply_progress={reply_progress}
has_task={Boolean(progress.task_id)}
/>
<Composer
bot={bot}
workspace_id={workspace_id}
extensions={pageProps?.bot_extensions}
onSent={({ message }) => {
if (startsNewSession(message) && message.session_id != session_id) {
setSessionId(Number(message.session_id));
setMessages([message]);
return;
}
setMessages((prev) =>
prev.some((m) => m.id == message.id)
? prev
: [...prev, message],
);
}}
/>
</Stack>
<ComputerViewerModal bot={live_bot} />
<SpeakMessageContext.Provider value={speak}>
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader
bot={live_bot}
workspace={workspace}
has_other_unread={has_other_unread}
harnesses={pageProps?.bot_harness_overview?.harnesses}
/>
<MessageThread
bot={live_bot}
messages={messages}
progress={progress.text}
reply_progress={reply_progress}
has_task={Boolean(progress.task_id)}
/>
<Composer
bot={bot}
workspace_id={workspace_id}
extensions={pageProps?.bot_extensions}
voice={pageProps?.bot_voice}
onSent={({ message }) => {
if (startsNewSession(message) && message.session_id != session_id) {
setSessionId(Number(message.session_id));
setMessages([message]);
return;
}
setMessages((prev) =>
prev.some((m) => m.id == message.id)
? prev
: [...prev, message],
);
}}
/>
</Stack>
<ComputerViewerModal bot={live_bot} />
</SpeakMessageContext.Provider>
</BotComputersContext.Provider>
);
}
@@ -21,7 +21,9 @@ export default function HarnessCurrentModel({ harness }: Props) {
}`
: harness.required
? "Nothing to run on yet: no provider has a usable key."
: "No free model can do this right now. Pick one to turn it on.";
: !harness.automatic
? "Not available until a model is picked."
: "No free model can do this right now. Pick one to turn it on.";
return (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
@@ -12,17 +12,20 @@ type Props = {
models: HarnessModelOption[];
model_id: number | null;
provider_name?: string;
automatic: boolean;
onChange: (model_id: number | null) => void;
};
/**
* Searchable model picker: automatic, or any model that can do the job.
* Searchable model picker: automatic (or not available, for a harness
* without automatic picks), or any model that can do the job.
*/
export default function HarnessModelSelect({
id,
models,
model_id,
provider_name,
automatic,
onChange,
}: Props) {
return (
@@ -32,7 +35,7 @@ export default function HarnessModelSelect({
label="Model"
showLabel
placeholder="Search models"
options={harnessModelSelectOptions({ models, model_id, provider_name })}
options={harnessModelSelectOptions({ models, model_id, provider_name, automatic })}
defaultValues={[model_id ? String(model_id) : AutoModelValue]}
changeHandler={(value) => {
const picked = Array.isArray(value) ? value[0] : value;
@@ -14,7 +14,8 @@ type Props = {
/**
* One harness: its provider, then its model (narrowed to that provider),
* whether it falls back to the automatic picks, whether it's on, and its
* whether it falls back to the automatic picks (for a harness that has
* them), whether it's on, and its
* voice for speech. On a bot's page the provider select can also use the
* default, which hides the rest.
*/
@@ -31,7 +32,7 @@ export default function HarnessSettingGroup({ harness, value, onChange }: Props)
? "turned off"
: harness.default?.current
? `${harness.default.current.name} on ${harness.default.current.provider_name}`
: "nothing available";
: "not available";
return (
<SettingsGroup title={harness.title} description={harness.description}>
@@ -60,6 +61,7 @@ export default function HarnessSettingGroup({ harness, value, onChange }: Props)
models={models}
model_id={value.model_id}
provider_name={provider?.name}
automatic={harness.automatic}
onChange={(model_id) => onChange({ ...value, model_id, voice: undefined })}
/>
)}
@@ -68,7 +70,7 @@ export default function HarnessSettingGroup({ harness, value, onChange }: Props)
) : (
<HarnessCurrentModel harness={harness} />
)}
{!value.inherit && value.model_id ? (
{!value.inherit && value.model_id && harness.automatic ? (
<Checkbox
title="Fall back to the automatic picks if this model fails"
label={`Fall back to the automatic picks${provider ? ` on ${provider.name}` : ""} if this model fails`}
@@ -13,24 +13,33 @@ type Params = {
* The picked provider's name; undefined for any provider.
*/
provider_name?: string;
/**
* False: there's no automatic pick, so the empty choice reads "Not
* available".
*/
automatic: boolean;
};
/**
* The model picker's options: automatic (the best free model, on the
* picked provider if there is one), then every model that can do the job
* (free ones first, as the server sorts them).
* (free ones first, as the server sorts them). A harness without
* automatic picks starts with "Not available" instead.
*/
export default function harnessModelSelectOptions({
models,
model_id,
provider_name,
automatic,
}: Params): TWUISelectOptionObject[] {
const has_free = models.some((model) => model.free);
return [
{
value: AutoModelValue,
title: provider_name
title: !automatic
? "Not available (pick a model to turn it on)"
: provider_name
? has_free
? `Automatic (best free model on ${provider_name})`
: `Automatic (no free models on ${provider_name}: pick one)`
+69
View File
@@ -0,0 +1,69 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabSpeakableMessage from "@/src/functions/backend/db/bots/grab-speakable-message";
import speakText from "@/src/harness/audio/speak-text";
import HarnessError from "@/src/harness/harness-error";
import { SpeakBotMessageRequestSchema } from "@/src/schemas/bot-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import speechText from "@/src/utils/speech-text";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* `GET ?message_id=`: reads one of the bot's messages in the user's own
* conversation aloud with the bot's speech model and voice, streamed as
* the provider makes it, so an `<audio>` pointed here starts playing
* within a couple of seconds. Closing the player stops the provider call.
* Nothing is stored; the browser may keep it for an hour.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, query }) => {
try {
if (req.method != "GET") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { message_id } = parseWithSchema({
schema: SpeakBotMessageRequestSchema,
data: query,
});
const { message, conversation } = await grabSpeakableMessage({
user_id: Number(user.id),
message_id,
});
const text = speechText({ text: message.text || "" });
if (!text) {
throw new AppError(`There's nothing to read in that message.`);
}
const audio = await speakText({
text,
bot_id: conversation.bot.id,
workspace_id: conversation.workspace_id,
signal: req.signal,
});
return new Response(audio.stream, {
headers: {
"Content-Type": audio.media_type,
"Cache-Control": "private, max-age=3600",
},
});
} catch (error: unknown) {
return {
success: false,
msg: error instanceof HarnessError ? error.message : safeErrorMessage({ error }),
};
}
};
+80
View File
@@ -0,0 +1,80 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
import transcribeAudio from "@/src/harness/audio/transcribe-audio";
import { TranscribeVoiceRequestSchema } from "@/src/schemas/bot-schema";
import HarnessError from "@/src/harness/harness-error";
import AppError from "@/src/utils/app-error";
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Recording types as the formats the harness takes.
*/
const AudioFormats: Record<string, string> = {
mp4: "m4a",
"x-m4a": "m4a",
mpeg: "mp3",
"x-wav": "wav",
wave: "wav",
};
/**
* Turns a composer dictation into text with the bot's transcription
* model, for the user to edit and send. The recording isn't kept.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<{ text: string }>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { bot_id, workspace_id, data_url } = parseWithSchema({
schema: TranscribeVoiceRequestSchema,
data: body,
});
const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id });
const type = data_url.match(/^data:audio\/([a-z0-9.+-]+)/i)?.[1]?.toLowerCase() || "webm";
const { buffer } = await bufferFromBase64(data_url);
if (!buffer.length) {
throw new AppError(`Nothing was recorded.`);
}
const text = await transcribeAudio({
audio: { data: new Uint8Array(buffer), format: AudioFormats[type] || type },
bot_id: conversation.bot.id,
workspace_id: conversation.workspace_id,
});
if (!text) {
throw new AppError(`No speech was heard. Try again a little closer to the mic.`);
}
return { success: true, singleRes: { text } };
} catch (error: unknown) {
return {
success: false,
msg: error instanceof HarnessError ? error.message : safeErrorMessage({ error }),
};
}
};