mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Add provider-backed dictation mode (#1327)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
import { UnifiedButton } from './ComposerButtons'
|
||||
import { DictationButton, UnifiedButton } from './ComposerButtons'
|
||||
|
||||
function renderInProviders(ui: ReactElement) {
|
||||
return render(<I18nProvider>{ui}</I18nProvider>)
|
||||
@@ -82,3 +82,24 @@ describe('UnifiedButton — routesToScratchlist visual state', () => {
|
||||
expect(btn.className).not.toContain('bg-amber-500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DictationButton', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('keeps dictation available when an existing draft makes the main button a send button', () => {
|
||||
const onVoiceToggle = vi.fn()
|
||||
renderInProviders(
|
||||
<DictationButton
|
||||
enabled
|
||||
canSend
|
||||
voiceEnabled
|
||||
voiceStatus="disconnected"
|
||||
controlsDisabled={false}
|
||||
onVoiceToggle={onVoiceToggle}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(getButton('Dictate'))
|
||||
expect(onVoiceToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -389,6 +389,7 @@ export function UnifiedButton(props: {
|
||||
controlsDisabled: boolean
|
||||
onSend: () => void
|
||||
onVoiceToggle: () => void
|
||||
voiceLabel?: string
|
||||
/**
|
||||
* When true, the send button repaints amber and the aria-label
|
||||
* announces "Send to scratchlist" instead of "Send message". The
|
||||
@@ -446,7 +447,7 @@ export function UnifiedButton(props: {
|
||||
} else if (props.voiceEnabled) {
|
||||
icon = <VoiceAssistantIcon />
|
||||
className = 'bg-black text-white'
|
||||
ariaLabel = t('composer.voice')
|
||||
ariaLabel = props.voiceLabel ?? t('composer.voice')
|
||||
} else {
|
||||
icon = <SendIcon />
|
||||
className = 'bg-[#C0C0C0] text-white'
|
||||
@@ -478,6 +479,37 @@ export function UnifiedButton(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function DictationButton(props: {
|
||||
enabled: boolean
|
||||
canSend: boolean
|
||||
voiceEnabled: boolean
|
||||
voiceStatus: ConversationStatus
|
||||
controlsDisabled: boolean
|
||||
onVoiceToggle: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (
|
||||
!props.enabled
|
||||
|| !props.canSend
|
||||
|| !props.voiceEnabled
|
||||
|| props.voiceStatus === 'connecting'
|
||||
|| props.voiceStatus === 'connected'
|
||||
) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onVoiceToggle}
|
||||
disabled={props.controlsDisabled}
|
||||
aria-label={t('composer.dictate')}
|
||||
title={t('composer.dictate')}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<VoiceAssistantIcon />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComposerButtons(props: {
|
||||
canSend: boolean
|
||||
controlsDisabled: boolean
|
||||
@@ -496,6 +528,7 @@ export function ComposerButtons(props: {
|
||||
isSwitching: boolean
|
||||
onSwitch: () => void
|
||||
voiceEnabled: boolean
|
||||
dictationEnabled?: boolean
|
||||
voiceStatus: ConversationStatus
|
||||
voiceMicMuted?: boolean
|
||||
onVoiceToggle: () => void
|
||||
@@ -739,6 +772,15 @@ export function ComposerButtons(props: {
|
||||
</OrderedToolbarItems>
|
||||
</div>
|
||||
|
||||
<DictationButton
|
||||
enabled={props.dictationEnabled ?? false}
|
||||
canSend={props.canSend}
|
||||
voiceEnabled={props.voiceEnabled}
|
||||
voiceStatus={props.voiceStatus}
|
||||
controlsDisabled={props.controlsDisabled}
|
||||
onVoiceToggle={props.onVoiceToggle}
|
||||
/>
|
||||
|
||||
<UnifiedButton
|
||||
canSend={props.canSend}
|
||||
voiceStatus={props.voiceStatus}
|
||||
@@ -746,6 +788,7 @@ export function ComposerButtons(props: {
|
||||
controlsDisabled={props.controlsDisabled}
|
||||
onSend={props.onSend}
|
||||
onVoiceToggle={props.onVoiceToggle}
|
||||
voiceLabel={props.dictationEnabled ? t('composer.dictate') : undefined}
|
||||
/*
|
||||
* Derived, NOT raw scratchlistMode. Mirror SessionChat's
|
||||
* shouldRouteToScratchlist: amber + "Send to scratchlist"
|
||||
|
||||
@@ -47,6 +47,9 @@ import { getPiThinkingLevelOptions, getHighestThinkingLevel, isThinkingLevelSupp
|
||||
import { groupModelsByProvider } from './piModelGroups'
|
||||
import { PiModelPanel } from './PiModelPanel'
|
||||
import { PiThinkingLevelPanel } from './PiThinkingLevelPanel'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import { useVoiceInputPreferences } from '@/hooks/useVoiceInputPreferences'
|
||||
import { useDictation } from '@/hooks/useDictation'
|
||||
|
||||
export interface TextInputState {
|
||||
text: string
|
||||
@@ -229,6 +232,7 @@ export function HappyComposer(props: {
|
||||
voiceMicMuted?: boolean
|
||||
onVoiceToggle?: () => void
|
||||
onVoiceMicToggle?: () => void
|
||||
voiceTranscriptionApi?: ApiClient
|
||||
// Schedule props (lifted from internal state when provided)
|
||||
pendingSchedule?: PendingSchedule | null
|
||||
onSchedule?: (pending: PendingSchedule) => void
|
||||
@@ -315,6 +319,40 @@ export function HappyComposer(props: {
|
||||
const attachments = useAuiState((s) => s.composer.attachments)
|
||||
const threadIsRunning = useAuiState((s) => s.thread.isRunning)
|
||||
const threadIsDisabled = useAuiState((s) => s.thread.isDisabled)
|
||||
const composerTextRef = useRef(composerText)
|
||||
composerTextRef.current = composerText
|
||||
const getCurrentComposerText = useCallback(() => composerTextRef.current, [])
|
||||
const setComposerText = useCallback((text: string) => api.composer().setText(text), [api])
|
||||
const voiceInput = useVoiceInputPreferences(props.voiceTranscriptionApi ?? null)
|
||||
const dictationConfig = useMemo(() => ({
|
||||
api: props.voiceTranscriptionApi ?? null,
|
||||
provider: voiceInput.provider,
|
||||
mode: voiceInput.transcriptionMode,
|
||||
getCurrentText: getCurrentComposerText,
|
||||
onTextChange: setComposerText
|
||||
}), [
|
||||
props.voiceTranscriptionApi,
|
||||
voiceInput.provider,
|
||||
voiceInput.transcriptionMode,
|
||||
getCurrentComposerText,
|
||||
setComposerText
|
||||
])
|
||||
const dictation = useDictation(dictationConfig)
|
||||
const dictationActive = voiceInput.voiceMode === 'dictation'
|
||||
const effectiveVoiceStatus = dictationActive ? dictation.status : voiceStatus
|
||||
const effectiveVoiceToggle = dictationActive
|
||||
? (dictation.supported ? dictation.toggle : undefined)
|
||||
: onVoiceToggle
|
||||
const previousVoiceModeRef = useRef(voiceInput.voiceMode)
|
||||
useEffect(() => {
|
||||
if (previousVoiceModeRef.current === voiceInput.voiceMode) return
|
||||
previousVoiceModeRef.current = voiceInput.voiceMode
|
||||
if (dictationActive && (voiceStatus === 'connected' || voiceStatus === 'connecting')) {
|
||||
onVoiceToggle?.()
|
||||
} else if (!dictationActive && (dictation.status === 'connected' || dictation.status === 'connecting')) {
|
||||
void dictation.toggle()
|
||||
}
|
||||
}, [dictationActive, voiceInput.voiceMode, voiceStatus, onVoiceToggle, dictation.status, dictation.toggle])
|
||||
|
||||
const controlsDisabled = disabled || (!active && !allowSendWhenInactive) || threadIsDisabled
|
||||
const trimmed = composerText.trim()
|
||||
@@ -889,7 +927,7 @@ export function HappyComposer(props: {
|
||||
|| showFastModeSettings
|
||||
)
|
||||
const showAbortButton = true
|
||||
const voiceEnabled = Boolean(onVoiceToggle)
|
||||
const voiceEnabled = Boolean(effectiveVoiceToggle)
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
flushAndSend()
|
||||
@@ -1380,9 +1418,15 @@ export function HappyComposer(props: {
|
||||
permissionMode={permissionMode}
|
||||
collaborationMode={collaborationMode}
|
||||
agentFlavor={agentFlavor}
|
||||
voiceStatus={voiceStatus}
|
||||
voiceStatus={effectiveVoiceStatus}
|
||||
/>
|
||||
|
||||
{dictationActive && dictation.error ? (
|
||||
<div role="alert" className="mb-2 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2 text-sm text-red-600">
|
||||
{dictation.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sendError ? (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -1467,10 +1511,11 @@ export function HappyComposer(props: {
|
||||
isSwitching={isSwitching}
|
||||
onSwitch={handleSwitch}
|
||||
voiceEnabled={voiceEnabled}
|
||||
voiceStatus={voiceStatus}
|
||||
voiceMicMuted={voiceMicMuted}
|
||||
onVoiceToggle={onVoiceToggle ?? (() => {})}
|
||||
onVoiceMicToggle={onVoiceMicToggle}
|
||||
dictationEnabled={dictationActive}
|
||||
voiceStatus={effectiveVoiceStatus}
|
||||
voiceMicMuted={dictationActive ? false : voiceMicMuted}
|
||||
onVoiceToggle={effectiveVoiceToggle ?? (() => {})}
|
||||
onVoiceMicToggle={dictationActive ? undefined : onVoiceMicToggle}
|
||||
onSend={handleSend}
|
||||
pendingSchedule={pendingSchedule}
|
||||
onSchedule={setPendingSchedule}
|
||||
|
||||
@@ -1573,6 +1573,7 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
voiceMicMuted={voice?.micMuted}
|
||||
onVoiceToggle={voice && voiceBackendReady ? handleVoiceToggle : undefined}
|
||||
onVoiceMicToggle={voice && voiceBackendReady ? handleVoiceMicToggle : undefined}
|
||||
voiceTranscriptionApi={props.api}
|
||||
scratchlistMode={scratchlistMode}
|
||||
scratchlistCount={scratchlist.entries.length}
|
||||
onScratchlistToggle={handleScratchlistToggle}
|
||||
|
||||
Reference in New Issue
Block a user