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:
@@ -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)`
|
||||
|
||||
Reference in New Issue
Block a user