feat(ui): add message text-to-speech controls
This commit is contained in:
@@ -14,6 +14,8 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
@@ -1384,6 +1386,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
const viewHideLabel = () =>
|
||||
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
||||
|
||||
const speech = useSpeech({
|
||||
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
|
||||
text: reasoningText,
|
||||
})
|
||||
|
||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
createEffect(() => {
|
||||
if (!expanded()) return
|
||||
reasoningText()
|
||||
@@ -1462,6 +1471,20 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
</button>
|
||||
|
||||
<div class="message-reasoning-actions">
|
||||
<Show when={canSpeakReasoning()}>
|
||||
<SpeechActionButton
|
||||
class="message-action-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void speech.toggle()
|
||||
}}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
|
||||
@@ -11,6 +11,8 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { isTauriHost } from "../lib/runtime-env"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
@@ -294,6 +296,13 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
const speech = useSpeech({
|
||||
id: () => `${props.instanceId}:${props.sessionId}:${props.record.id}`,
|
||||
text: getRawContent,
|
||||
})
|
||||
|
||||
const canSpeakMessage = () => getRawContent().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
const handleCopy = async () => {
|
||||
const content = getRawContent()
|
||||
if (!content) return
|
||||
@@ -443,6 +452,16 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={canSpeakMessage()}>
|
||||
<SpeechActionButton
|
||||
class="message-action-button"
|
||||
onClick={() => void speech.toggle()}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
@@ -503,6 +522,16 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={canSpeakMessage()}>
|
||||
<SpeechActionButton
|
||||
class="message-action-button"
|
||||
onClick={() => void speech.toggle()}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
|
||||
31
packages/ui/src/components/speech-action-button.tsx
Normal file
31
packages/ui/src/components/speech-action-button.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Loader2, Square, Volume2 } from "lucide-solid"
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
interface SpeechActionButtonProps {
|
||||
class?: string
|
||||
title: string
|
||||
isLoading: boolean
|
||||
isPlaying: boolean
|
||||
onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
type?: "button" | "submit" | "reset"
|
||||
}
|
||||
|
||||
export default function SpeechActionButton(props: SpeechActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={props.type ?? "button"}
|
||||
class={props.class}
|
||||
onClick={props.onClick}
|
||||
aria-label={props.title}
|
||||
title={props.title}
|
||||
>
|
||||
{props.isLoading ? (
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
|
||||
) : props.isPlaying ? (
|
||||
<Square class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
ToolScrollHelpers,
|
||||
} from "./tool-call/types"
|
||||
import {
|
||||
buildToolSpeechText,
|
||||
ensureMarkdownContent,
|
||||
getRelativePath,
|
||||
getToolIcon,
|
||||
@@ -41,6 +42,8 @@ import {
|
||||
} from "./tool-call/utils"
|
||||
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -960,6 +963,21 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return renderToolTitle()
|
||||
})
|
||||
|
||||
const speechText = createMemo(() =>
|
||||
buildToolSpeechText({
|
||||
title: headerText(),
|
||||
state: toolState(),
|
||||
t,
|
||||
}),
|
||||
)
|
||||
|
||||
const speech = useSpeech({
|
||||
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId ?? "message"}:${toolCallIdentifier()}`,
|
||||
text: speechText,
|
||||
})
|
||||
|
||||
const canSpeakToolCall = () => speechText().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
const handleCopyHeader = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -1023,6 +1041,16 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<Show when={canSpeakToolCall()}>
|
||||
<SpeechActionButton
|
||||
class="tool-call-header-copy"
|
||||
onClick={() => void speech.toggle()}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<span class="tool-call-header-status" aria-hidden="true">
|
||||
{statusIcon()}
|
||||
</span>
|
||||
|
||||
@@ -231,3 +231,37 @@ export function getDefaultToolAction(toolName: string) {
|
||||
return tGlobal("toolCall.renderer.action.working")
|
||||
}
|
||||
}
|
||||
|
||||
export function buildToolSpeechText(options: {
|
||||
title: string
|
||||
state?: ToolState
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}): string {
|
||||
const sections: string[] = []
|
||||
|
||||
if (options.title.trim()) {
|
||||
sections.push(options.title.trim())
|
||||
}
|
||||
|
||||
const { input, output } = readToolStatePayload(options.state)
|
||||
const formattedInput = formatUnknown(input)
|
||||
const formattedOutput = formatUnknown(output)
|
||||
|
||||
if (formattedInput?.text?.trim()) {
|
||||
sections.push(`${options.t("toolCall.io.input")}:\n${formattedInput.text.trim()}`)
|
||||
}
|
||||
|
||||
if (formattedOutput?.text?.trim()) {
|
||||
sections.push(`${options.t("toolCall.io.output")}:\n${formattedOutput.text.trim()}`)
|
||||
}
|
||||
|
||||
if (options.state?.status === "error" && options.state.error?.trim()) {
|
||||
sections.push(`${options.t("toolCall.error.label")} ${options.state.error.trim()}`)
|
||||
}
|
||||
|
||||
if (sections.length === 1 && options.state?.status === "pending") {
|
||||
sections.push(options.t("toolCall.pending.waitingToRun"))
|
||||
}
|
||||
|
||||
return sections.join("\n\n").trim()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user