通用语音输入模块

在知与行 App 中,新建计划、新建感悟、神秘人聊天三个场景都需要文字输入。当前均依赖键盘打字,体验割裂。

by 于尘 ·
知与行 React Native Agent

通用语音输入模块

记录日期:2026-04-11

1. 背景与目标

在知与行 App 中,新建计划、新建感悟、神秘人聊天三个场景都需要文字输入。当前均依赖键盘打字,体验割裂。

目标:实现一个通用的语音输入转文字模块,三个场景共用同一底层,复用 UI 和交互,一处优化三处生效。


2. 设计方案

2.1 技术选型

方案 优点 缺点 推荐
iOS Speech Framework 系统级集成,精度高,无需网络 仅 iOS iOS 主方案
Android SpeechRecognizer 系统级集成 仅 Android Android 主方案
Web Speech API 跨平台,零依赖 依赖浏览器支持,精度一般 Web 主方案
第三方 API(MiniMax/DeepSeek) 精度高,跨平台 有延迟,需积分,耗流量 降级兜底

结论:优先用平台原生 SDK,Web 用 Speech API,失败时降级到服务端 API。

2.2 模块结构

src/
      ├── lib/
      │   └── voice-input/              # 通用语音输入模块
      │       ├── index.ts              # 统一导出
      │       ├── VoiceInputEngine.ts   # 核心引擎(平台分发)
      │       ├── VoiceInputEngine.ios.ts
      │       ├── VoiceInputEngine.android.ts
      │       ├── VoiceInputEngine.web.ts
      │       ├── VoiceInputEngine.fallback.ts  # 服务端兜底
      │       └── types.ts
      ├── hooks/
      │   └── useVoiceInput.ts          # React Hook,封装状态和交互
      └── components/
          └── VoiceInputButton.tsx     # 可复用的麦克风按钮组件
      

2.3 核心接口

// types.ts
      export type VoiceInputState = 'idle' | 'listening' | 'processing' | 'error' | 'disabled'
      
      export interface VoiceInputResult {
        text: string
        confidence: number   // 0-1
        isFinal: boolean
        error?: string
      }
      
      export interface VoiceInputOptions {
        language?: string       // 默认 'zh-CN'
        interim?: boolean       // 是否返回临时结果,默认 true
        onResult?: (result: VoiceInputResult) => void
        onError?: (error: string) => void
        onEnd?: () => void
      }
      
      export interface VoiceInputEngine {
        state: VoiceInputState
        start(options: VoiceInputOptions): void
        stop(): void
        isSupported(): boolean
      }
      
// VoiceInputEngine.ts — 平台分发
      import { Platform } from 'react-native'
      import { VoiceInputEngineIos } from './VoiceInputEngine.ios'
      import { VoiceInputEngineAndroid } from './VoiceInputEngine.android'
      import { VoiceInputEngineWeb } from './VoiceInputEngine.web'
      import { VoiceInputEngineFallback } from './VoiceInputEngine.fallback'
      
      export function createVoiceInputEngine(): VoiceInputEngine {
        if (Platform.OS === 'ios') return new VoiceInputEngineIos()
        if (Platform.OS === 'android') return new VoiceInputEngineAndroid()
        if (typeof window !== 'undefined' && 'webkitSpeechRecognition' in window) return new VoiceInputEngineWeb()
        return new VoiceInputEngineFallback()
      }
      

2.4 Hook 封装

// useVoiceInput.ts
      import { useRef, useState, useCallback, useEffect } from 'react'
      import { Alert, Platform } from 'react-native'
      import { createVoiceInputEngine, type VoiceInputResult, type VoiceInputOptions } from '../lib/voice-input'
      
      export function useVoiceInput(onTranscription: (text: string) => void) {
        const engineRef = useRef<ReturnType<typeof createVoiceInputEngine> | null>(null)
        const [state, setState] = useState<VoiceInputState>('idle')
      
        useEffect(() => {
          engineRef.current = createVoiceInputEngine()
          return () => engineRef.current?.stop()
        }, [])
      
        const start = useCallback(() => {
          if (!engineRef.current) return
          if (!engineRef.current.isSupported()) {
            Alert.alert('不支持语音', '当前设备不支持语音输入')
            return
          }
          engineRef.current.start({
            language: 'zh-CN',
            interim: true,
            onResult: (result: VoiceInputResult) => {
              const text = result.text.trim()
              if (text) onTranscription(text)
            },
            onError: (error: string) => {
              setState('error')
              Alert.alert('语音识别失败', error)
            },
            onEnd: () => setState('idle'),
          })
          setState('listening')
        }, [onTranscription])
      
        const stop = useCallback(() => {
          engineRef.current?.stop()
          setState('idle')
        }, [])
      
        return { state, start, stop, isListening: state === 'listening' }
      }
      

2.5 UI 组件

// VoiceInputButton.tsx
      import React from 'react'
      import { TouchableOpacity, StyleSheet, Animated } from 'react-native'
      import { Ionicons } from '@expo/vector-icons'
      
      interface VoiceInputButtonProps {
        isListening: boolean
        disabled?: boolean
        onPressIn: () => void   // 按下开始录音
        onPressOut: () => void  // 松开停止录音
      }
      
      export function VoiceInputButton({ isListening, disabled, onPressIn, onPressOut }: VoiceInputButtonProps) {
        const pulseAnim = React.useRef(new Animated.Value(1)).current
      
        React.useEffect(() => {
          if (isListening) {
            const anim = Animated.loop(
              Animated.sequence([
                Animated.timing(pulseAnim, { toValue: 1.15, duration: 600, useNativeDriver: true }),
                Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
              ])
            )
            anim.start()
            return () => anim.stop()
          } else {
            pulseAnim.setValue(1)
          }
        }, [isListening])
      
        return (
          <Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
            <TouchableOpacity
              onPressIn={onPressIn}
              onPressOut={onPressOut}
              disabled={disabled}
              style={[
                styles.btn,
                isListening && styles.btnListening,
                disabled && styles.btnDisabled,
              ]}
              accessibilityLabel="语音输入"
            >
              <Ionicons
                name={isListening ? 'mic' : 'mic-outline'}
                size={20}
                color={isListening ? '#fff' : disabled ? '#9CA3AF' : '#4F46E5'}
              />
            </TouchableOpacity>
          </Animated.View>
        )
      }
      
      const styles = StyleSheet.create({
        btn: {
          width: 36,
          height: 36,
          borderRadius: 18,
          backgroundColor: '#EEF2FF',
          alignItems: 'center',
          justifyContent: 'center',
        },
        btnListening: {
          backgroundColor: '#EF4444',
        },
        btnDisabled: {
          opacity: 0.5,
        },
      })
      

2.6 接入场景

新建计划页面(new-plan)

在计划输入框右侧添加 VoiceInputButtononTranscription 将文字追加到 TextInput:

const { state, start, stop, isListening } = useVoiceInput((text) => {
        setPlanTitle(prev => prev + text)
      })
      
      <View style={styles.inputRow}>
        <TextInput value={planTitle} onChangeText={setPlanTitle} ... />
        <VoiceInputButton
          isListening={isListening}
          onPressIn={start}
          onPressOut={stop}
        />
      </View>
      

新建感悟页面(edit-insight)

同上,onTranscription 追加到感悟内容 TextInput。

神秘人聊天(agent-chat)

聊天输入栏底部添加 VoiceInputButtononTranscription 将文字填入输入框,支持发送。

2.7 权限处理

// VoiceInputEngine.ios.ts
      import { Platform, PermissionsAndroid } from 'react-native'
      import { request, PERMISSIONS, RESULTS } from 'react-native-permissions'
      
      export async function requestMicrophonePermission(): Promise<boolean> {
        if (Platform.OS === 'ios') {
          const result = await request(PERMISSIONS.IOS.SPEECH_RECOGNITION)
          return result === RESULTS.GRANTED
        }
        if (Platform.OS === 'android') {
          const granted = await PermissionsAndroid.request(
            PermissionsAndroid.PERMISSIONS.RECORD_AUDIO
          )
          return granted === PermissionsAndroid.RESULTS.GRANTED
        }
        return false
      }
      

3. 实现计划

阶段 内容 优先级
Phase 1 核心引擎 + types + 平台分发 P0
Phase 2 iOS/Android/Web 引擎实现 P0
Phase 3 useVoiceInput Hook P0
Phase 4 VoiceInputButton 组件 P1
Phase 5 接入新建计划页面 P1
Phase 6 接入新建感悟页面 P1
Phase 7 接入神秘人聊天页面 P2
Phase 8 降级兜底:服务端 API P2

4. 小结

通用语音输入模块通过平台分发引擎 + React Hook 封装 + 可复用按钮组件三层结构,实现"一处开发、三处复用"。

核心设计要点:

  • 引擎抽象层隔离平台差异,上层无需感知具体实现
  • useVoiceInput Hook 封装状态管理,每个接入场景 < 10 行代码
  • 按住说话(press-in)、松开停止(press-out)的交互符合移动端直觉
  • Web 端零依赖,Native 端零额外依赖(利用系统 Speech Framework)
← 所有文章

Agent-create_plan参数提取与confirm流程修复

# Agent create_plan 参数提取与 confirm 流程(当前实现)

by 于尘 ·
知与行 CloudBase

Agent create_plan 参数提取与 confirm 流程(当前实现)

1.问题背景

用户说「给我创建一个计划,标题是跑步」时,风险点有两类:

  1. plan_title 被 LLM 填成整句(含“给我创建一个计划”这类元指令)。
  2. 确认流程字段传错导致工具未执行或执行错参数。

当前系统已切到 interrupt/resume 两轮确认,不再使用旧的 persistence_node / confirm_recovery_node 口径。

2.问题列表

2.1 问题一:plan_title 抽取质量不稳定(模型行为问题)

根因(链式证据级定位,最多3轮):

  • why1: 为什么标题偶发带元指令?
    • 因为参数提取由 LLM 完成,本质是生成行为,不是规则解析。
  • why2: 为什么会影响确认与落库?
    • 因为后端当前不做 plan_title 归一化,工具按收到参数直接执行。
  • why3: 为什么不在后端强行正则清洗?
    • 因为清洗规则容易误伤真实标题,且会引入隐式魔法行为。

方案(每个问题不超过两个方案):

  • 短期方案: 在工具说明里强化参数语义约束(当前已做)。
    • 优点: 改动小、与 LLM 机制一致。
    • 缺点: 不是 100% 确定性。
  • 长远方案: 前端确认弹窗默认支持 edit 决策,用户可改标题后再执行。
    • 优点: 用户可控、结果确定。
    • 缺点: 交互更复杂。

采纳方案:

  • 当前采纳“短期方案 + 长远兜底”:
    • 工具 docstring 强约束 plan_title 只填核心标题。
    • 保留 edit 决策链路用于纠偏(后端已支持)。

补充用例:

  • tests/e2e/test_012_edit_decision.py:验证确认轮 edit 后参数生效。

2.2 问题二:confirm 恢复请求字段错传导致执行失败

根因(链式证据级定位,最多3轮):

  • why1: 为什么用户点确认后偶发不执行?
    • 因为前端若未按协议传 forwardedProps.command.resume,后端不会进入恢复执行。
  • why2: 为什么这在旧文档里容易被误配?
    • 因为历史文档混用了 state.confirm、role=tool 等旧口径。
  • why3: 为什么必须统一?
    • 因为当前工作流只认 Command(resume=...)

方案(每个问题不超过两个方案):

  • 短期方案: 文档统一到 resume.decisions + pending_calls
    • 优点: 立刻减少错传。
    • 缺点: 依赖前端严格执行。
  • 长远方案: 前后端加协议契约测试(真实 SSE 回放 + confirm 二轮)。
    • 优点: 回归可自动拦截。
    • 缺点: 测试建设成本更高。

采纳方案:

  • 采纳长远方案方向,现阶段已完成文档口径统一,并保留 e2e confirm 用例覆盖。

补充用例:

  • Round1 必须拿到 pending_calls
  • Round2 必须传 resume.decisionsresume.pending_calls
  • 结果必须出现 TOOL_CALL_START/ENDcreate_plan 工具结果。

2.3 问题三:前端确认后误回放上一轮 assistant 文本(显示层问题)

根因(链式证据级定位,最多3轮):

  • why1: 为什么确认后会多出一条旧回复?
    • 因为确认回合里 result.content 有时是快照回放文本。
  • why2: 为什么会被直接展示?
    • 因为前端曾优先使用 llmText 作为确认后的展示内容。
  • why3: 为什么影响用户感知?
    • 因为用户会看到“重复回复”,误以为执行了两次。

方案(每个问题不超过两个方案):

  • 短期方案: 前端优先结构化工具结果,并过滤“与上一条 assistant 完全相同”的回放文本。
    • 优点: 立刻止血。
    • 缺点: 是展示层防线。
  • 长远方案: 协议层明确区分确认回合的“新生成文本”与“快照回放文本”。
    • 优点: 从协议源头解决。
    • 缺点: 需前后端联动改造。

采纳方案:

  • 当前采纳短期方案,已在前端确认回合生效。

补充用例:

  • confirm 后不应重复追加上一轮 assistant 文本。
  • 无结构化结果时应回退到工具摘要文案。

3.小结

当前 create_plan 链路是:

  1. 参数提取由 LLM 主导,靠工具描述做语义约束。
  2. 写工具执行走 interrupt/resume 两轮确认。
  3. 前端确认展示层已增加“重复回放过滤”。

下一步最值得做的是:把 create_plan 的“标题提取准确率”做成可量化回归样本(真实语料回放 + 判定),而不是继续堆正则。

← 所有文章