Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions docs/demos/sender/voice-input.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,44 @@
import { ref } from 'vue'
import { TrSender, VoiceButton } from '@opentiny/tiny-robot'

const voiceMode = ref<'mixed' | 'continuous'>('mixed')
const voiceMode = ref<'append' | 'replace'>('append')
</script>

<template>
<div style="display: flex; flex-direction: column; gap: 16px">
<div style="display: flex; align-items: center; gap: 12px">
<span style="font-weight: 500">模式:</span>
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer">
<input type="radio" value="mixed" v-model="voiceMode" style="cursor: pointer" />
<span>混合输入</span>
<input type="radio" value="append" v-model="voiceMode" style="cursor: pointer" />
<span>追加模式</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer">
<input type="radio" value="continuous" v-model="voiceMode" style="cursor: pointer" />
<span>连续识别</span>
<input type="radio" value="replace" v-model="voiceMode" style="cursor: pointer" />
<span>替换模式</span>
</label>
</div>
<div style="padding: 8px 12px; background: #f5f7fa; border-radius: 4px; font-size: 13px; color: #666">
{{ voiceMode === 'mixed' ? '语音识别结果追加到输入框,可继续编辑' : '持续识别语音并自动替换内容' }}
{{
voiceMode === 'append'
? '追加模式:每次语音识别结果会追加到输入框末尾,适合混合输入'
: '替换模式:每次语音识别会替换输入框全部内容,适合纯语音输入'
}}
</div>
<tr-sender
:key="voiceMode"
mode="multiple"
:placeholder="voiceMode === 'mixed' ? '点击麦克风说话,识别结果会追加到此处...' : '点击麦克风开始连续识别...'"
:placeholder="
voiceMode === 'append'
? '可以打字或点击麦克风说话,语音内容会追加...'
: '点击麦克风说话,每次识别会替换全部内容...'
"
>
<template #footer-right>
<VoiceButton
:speech-config="
voiceMode === 'mixed'
voiceMode === 'append'
? { autoReplace: false, interimResults: true }
: { autoReplace: true, continuous: true }
: { autoReplace: true, interimResults: true }
"
/>
</template>
Expand Down
28 changes: 20 additions & 8 deletions docs/src/components/sender.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
---
---
outline: [1, 3]
---

Expand Down Expand Up @@ -155,10 +155,23 @@ TrSender.Suggestion.configure({ items: suggestions, filterFn: customFilter })

#### 基础语音识别

使用浏览器内置的语音识别功能,支持混合输入和连续识别两种模式。
使用浏览器内置的语音识别功能,支持混合输入和连续识别两种模式。可通过 `speechConfig.lang` 显式指定识别语言。

<demo vue="../../demos/sender/voice-input.vue" title="基础语音输入" description="使用浏览器内置语音识别,支持混合输入和连续识别。" />

:::tip lang 语言说明
`lang` 用于指定语音识别语言,建议显式传入,并与页面的 `html lang` 保持一致,避免页面语言和浏览器环境语言不一致时出现识别偏差。

常见取值示例:

| 值 | 说明 |
| --- | --- |
| `en` | 英语 |
| `zh` | 中文 |
| `zh-CN` | 简体中文 |
| `en-US` | 美式英语 |
:::

#### 自定义语音服务

支持集成第三方语音识别服务(如阿里云、百度、Azure 等)。
Expand Down Expand Up @@ -429,7 +442,7 @@ onSelect: (item) => {
| tooltipPlacement | Tooltip 位置 | `TooltipPlacement` | `'top'` |
| speechConfig | 语音配置 | `SpeechConfig` | - |
| autoInsert | 是否自动插入识别结果到编辑器 | `boolean` | `true` |
| onButtonClick | 按钮点击拦截器 | `Function` | - |
| onButtonClick | 按钮点击拦截器 | `(isRecording: boolean, preventDefault: () => void) => void \| Promise<void>` | - |

## Slots

Expand Down Expand Up @@ -597,11 +610,10 @@ type TooltipPlacement =
// SpeechConfig 语音配置
interface SpeechConfig {
customHandler?: SpeechHandler // 自定义语音处理器
lang?: string // 识别语言,默认浏览器语言
continuous?: boolean // 是否持续识别
interimResults?: boolean // 是否返回中间结果
autoReplace?: boolean // 是否自动替换内容
onVoiceButtonClick?: (isRecording, preventDefault) => void // 按钮点击拦截器
lang?: string // 内置 Web Speech 的识别语言;未传入时使用 navigator.language
continuous?: boolean // 内置 Web Speech 是否持续识别
interimResults?: boolean // 内置 Web Speech 是否返回中间结果
autoReplace?: boolean // 是否在本次录音期间用最新识别结果替换当前语音插入内容
}

// 模板项(联合类型)
Expand Down
55 changes: 48 additions & 7 deletions packages/components/src/sender-actions/voice-button/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useSenderContext } from '../../sender/context'
import { useSpeechHandler } from './useSpeechHandler'
import ActionButton from '../action-button/index.vue'
Expand All @@ -16,23 +16,62 @@ const emit = defineEmits<VoiceButtonEmits>()
// 从 Context 获取最小依赖:只需要 editor 和 disabled
const { editor, disabled: contextDisabled } = useSenderContext()
const isDisabled = computed(() => props.disabled || contextDisabled.value)
const speechRange = ref<{ from: number; to: number } | null>(null)

const resetSpeechRange = () => {
speechRange.value = null
}

const insertTranscript = (transcript: string) => {
if (!props.autoInsert || !editor.value || !transcript) return

const editorInstance = editor.value
const autoReplace = props.speechConfig?.autoReplace ?? false

if (!autoReplace) {
editorInstance.commands.insertContent(transcript + ' ')
editorInstance.commands.focus('end')
return
}

// autoReplace 模式:替换整个输入框内容
if (speechRange.value === null) {
// 首次插入,记录起始位置为 0
speechRange.value = {
from: 0,
to: 0,
}
}

// 替换从起始位置到当前内容末尾的所有文本
const docSize = editorInstance.state.doc.content.size
const tr = editorInstance.state.tr.insertText(transcript, speechRange.value.from, docSize)
editorInstance.view.dispatch(tr)

// 更新范围,保持起始位置不变,更新结束位置
speechRange.value = {
from: speechRange.value.from,
to: speechRange.value.from + transcript.length,
}
editorInstance.commands.focus('end')
}

// 语音配置 - 使用普通对象而不是 computed,避免每次都创建新对象
const speechOptions = {
...props.speechConfig,
onStart: () => {
resetSpeechRange()
emit('speech-start')
},
onInterim: (transcript: string) => {
if (props.speechConfig?.autoReplace) {
insertTranscript(transcript)
}
emit('speech-interim', transcript)
},
onFinal: (transcript: string) => {
// 自动插入到编辑器(可配置)
if (props.autoInsert && editor.value) {
// 插入内容
editor.value.commands.insertContent(transcript + ' ')
// 确保光标在内容末尾
editor.value.commands.focus('end')
if (!props.speechConfig?.autoReplace) {
insertTranscript(transcript)
}
emit('speech-final', transcript)
},
Expand All @@ -41,9 +80,11 @@ const speechOptions = {
if (editor.value) {
editor.value.commands.focus('end')
}
resetSpeechRange()
emit('speech-end', transcript)
},
onError: (error: Error) => {
resetSpeechRange()
emit('speech-error', error)
},
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/**
* 语音识别相关类型定义
*/
// 语音回调函数集合
Expand Down Expand Up @@ -27,8 +27,7 @@ export interface SpeechConfig {
lang?: string // 识别语言,默认浏览器语言
continuous?: boolean // 是否持续识别
interimResults?: boolean // 是否返回中间结果
autoReplace?: boolean // 是否自动替换当前输入内容
onVoiceButtonClick?: (isRecording: boolean, preventDefault: () => void) => void | Promise<void> // 录音按钮点击拦截器
autoReplace?: boolean // 是否在本次录音期间自动替换语音插入内容
}

// 语音识别状态
Expand Down
Loading