适合谁看

  • 想在鸿蒙端把 AI 助手接进 Flutter 应用的人

  • 不想把 AI 功能做成"孤立聊天页"的开发者

  • 想理解鸿蒙原生能力(语音识别、TTS)如何通过 Platform Channel 被 Flutter AI 页面消费的人

  • 想理解页面、协调器、工具调用怎么串起来的人

问题背景

很多 AI 页面一开始都很好做:

  • 放一个输入框

  • 调一下模型

  • 展示返回文本

但到了真实项目里,很快就会出现更难的问题:

  • 用户从别的页面带着问题进来怎么办

  • AI 回复和业务卡片要不要一起展示

  • 页面退出时语音播报要不要停

  • 对话状态、语音状态和页面状态怎么配合

  • 鸿蒙端的语音识别和 TTS 能力怎么被 Flutter 页面调用(Android/iOS 各有一套,鸿蒙也有一套)

  • 权限申请、引擎生命周期管理在鸿蒙侧怎么处理

这也是为什么"能不能聊天"并不是关键,关键是 AI 助手怎么被嵌进原有应用结构里——尤其是鸿蒙跨语言桥接这一层。

项目中的真实场景

食界探味当前的 AI 助手相关代码主要在:

Flutter 侧(AI 页面 + 协调器):

  • app/lib/features/ai_assistant/screens/ai_assistant_screen.dart — AI 对话页面

  • app/lib/core/ai/ai_explore_coordinator.dart — AI 流程编排协调器

  • app/lib/core/ai/agent_service.dart — 模型调用统一封装

  • app/lib/core/ai/models/ai_session_state.dart — 会话状态模型

  • app/lib/core/ai/tools/ — 工具层(搜索菜品、菜品详情、随机推荐等)

Flutter 侧(Platform Channel 封装):

  • app/lib/core/platform/speech_recognition_channel.dart — 语音识别通道

  • app/lib/core/platform/text_to_speech_channel.dart — TTS 通道

鸿蒙侧(原生插件):

  • app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets — 鸿蒙语音识别插件

  • app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets — 鸿蒙 TTS 插件

路由入口:

  • app/lib/app.dart

同时它还会从别的页面被带起,比如:

  • 搜索页把 query 带到 /ai-assistant

  • 菜品详情页带着"推荐类似吃法"的问题进来

  • 探索页有 AI 入口卡片

这说明当前 AI 助手不是单点功能,而是已经进入了应用路由和页面流转主线。

核心实现

先说结论:

食界探味的 AI 助手不是"页面里直接调模型",而是"Flutter 页面 + 协调器 + AgentService + 工具调用 + 鸿蒙原生语音能力 + 菜品卡片渲染"这一整条组合链。

一、鸿蒙侧:原生能力是怎么暴露给 Flutter 的

在讲 Flutter 页面之前,先把鸿蒙侧的基础打好。AI 助手最依赖的两个鸿蒙原生能力是语音识别文本转语音,它们都来自鸿蒙的 @kit.CoreSpeechKit

1.1 语音识别插件(SpeechRecognitionPlugin.ets)

鸿蒙侧的语音识别插件实现了 FlutterPlugin 接口,通过 MethodChannel 与 Flutter 通信:

// app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets

import { speechRecognizer } from '@kit.CoreSpeechKit';
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit';

export default class SpeechRecognitionPlugin implements FlutterPlugin, MethodCallHandler {
  private channel: MethodChannel | null = null;
  private asrEngine: speechRecognizer.SpeechRecognitionEngine | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(
      binding.getBinaryMessenger(),
      'com.foodvoyage.speech_recognition'
    );
    this.channel.setMethodCallHandler(this);
  }

关键流程:

  1. 权限申请 — 鸿蒙必须先动态申请麦克风权限:

private async requestMicrophonePermission(): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();
  const permissions: Permissions[] = ['ohos.permission.MICROPHONE'];
  const context = getContext(this);
  const grantResult = await atManager.requestPermissionsFromUser(context, permissions);
  return grantResult.authResults.every(
    status => status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED
  );
}
  1. 创建识别引擎 — 使用在线模式,语言设为 zh-CN

private createEngine(): Promise<void> {
  return new Promise((resolve, reject) => {
    const initParams: speechRecognizer.CreateEngineParams = {
      language: 'zh-CN',
      online: 1,
      extraParams: { 'locate': 'CN', 'recognizerMode': 'short' }
    };

    speechRecognizer.createEngine(initParams, (err, engine) => {
      if (!err) {
        this.asrEngine = engine;
        resolve();
      } else {
        reject(err);
      }
    });
  });
}
  1. 开始识别 — 设置监听器后发起识别:

private startListening(): void {
  if (!this.asrEngine) return;

  const audioParam: speechRecognizer.AudioInfo = {
    audioType: 'pcm',
    sampleRate: 16000,
    soundChannel: 1,
    sampleBit: 16
  };
  const recognizerParams: speechRecognizer.StartParams = {
    sessionId: this.sessionId,
    audioInfo: audioParam,
    extraParams: {
      'recognitionMode': 0,
      'vadBegin': 2000,
      'vadEnd': 3000,
      'maxAudioDuration': 20000
    }
  };

  this.asrEngine.startListening(recognizerParams);
}
  1. 结果回传 — 识别到最终结果时,通过 MethodChannel 把文本传回 Flutter:

onResult: (sessionId, result) => {
  if (result.isLast && this.pendingResult) {
    this.pendingResult.success(result.result);  // 回传给 Flutter
    this.pendingResult = null;
    this.shutdownEngine();
  }
}
  1. 引擎清理 — 识别完成后立即 shutdown,避免资源泄漏:

private shutdownEngine(): void {
  if (this.asrEngine) {
    this.asrEngine.shutdown();
    this.asrEngine = null;
  }
}
1.2 TTS 插件(TextToSpeechPlugin.ets)

TTS 插件同样实现了 FlutterPlugin,使用鸿蒙的 textToSpeech 能力:

// app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets

import { textToSpeech } from '@kit.CoreSpeechKit';

export default class TextToSpeechPlugin implements FlutterPlugin, MethodCallHandler {
  private ttsEngine: textToSpeech.TextToSpeechEngine | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(
      binding.getBinaryMessenger(),
      'com.foodvoyage.text_to_speech'
    );
    this.channel.setMethodCallHandler(this);
  }

TTS 的创建和播报:

private createEngine(): Promise<void> {
  return new Promise((resolve, reject) => {
    if (this.ttsEngine) { resolve(); return; }

    const initParams: textToSpeech.CreateEngineParams = {
      language: 'zh-CN',
      person: 0,
      online: 1,
      extraParams: {
        'style': 'interaction-broadcast',
        'locate': 'CN',
        'name': 'EngineName'
      }
    };

    textToSpeech.createEngine(initParams, (err, engine) => {
      if (!err) {
        this.ttsEngine = engine;
        resolve();
      } else {
        reject(err);
      }
    });
  });
}

播报时设置回调监听,播报完成后回传结果给 Flutter:

private setupListenerAndSpeak(text: string): void {
  if (!this.ttsEngine) return;

  const speakListener: textToSpeech.SpeakListener = {
    onStart: (requestId, response) => { /* 开始 */ },
    onComplete: (requestId, response) => {
      if (this.pendingResult) {
        this.pendingResult.success(null);  // 通知 Flutter 播报完成
        this.pendingResult = null;
      }
    },
    onStop: (requestId, response) => { /* 停止 */ },
    onData: (requestId, audio, response) => { /* 音频数据 */ },
    onError: (requestId, errorCode, errorMessage) => {
      if (this.pendingResult) {
        this.pendingResult.error('TTS_ERROR', errorMessage, null);
        this.pendingResult = null;
      }
    }
  };

  this.ttsEngine.setListener(speakListener);
  this.ttsEngine.speak(text, {
    requestId: `tts_${Date.now()}`,
    extraParams: {
      'queueMode': 0,
      'speed': 1,
      'volume': 2,
      'pitch': 1,
      'languageContext': 'zh-CN',
      'audioType': 'pcm',
      'soundChannel': 3,
      'playType': 1
    }
  });
}
1.3 Flutter 侧的通道封装

鸿蒙原生插件注册好之后,Flutter 侧只需要通过 MethodChannel 调用即可:

// app/lib/core/platform/speech_recognition_channel.dart

class SpeechRecognitionChannel {
  static const _channel = MethodChannel('com.foodvoyage.speech_recognition');

  static Future<String> startListening({String language = 'zh-CN'}) async {
    final result = await _channel.invokeMethod<String>(
      'startListening',
      {'language': language},
    );
    return result ?? '';
  }

  static Future<void> stopListening() async {
    await _channel.invokeMethod<void>('stopListening');
  }
}
// app/lib/core/platform/text_to_speech_channel.dart

class TextToSpeechChannel {
  static const _channel = MethodChannel('com.foodvoyage.text_to_speech');

  static Future<void> speak(String text) async {
    await _channel.invokeMethod<void>('speak', {'text': text});
  }

  static Future<void> stop() async {
    await _channel.invokeMethod<void>('stop');
  }
}

这里的关键设计是:Flutter 侧的 Channel 封装是平台无关的。同一套 SpeechRecognitionChannel / TextToSpeechChannel 在鸿蒙端走鸿蒙的 CoreSpeechKit,在 Android 端走 Android SpeechRecognizer,在 iOS 端走 AVSpeechSynthesizer。AI 页面完全不感知底层平台差异。

二、路由层先给 AI 助手一个正式入口

app/lib/app.dart 里,当前已经有:

GoRoute(
  path: '/ai-assistant',
  redirect: (context, state) {
    if (!AppConfig.enableAi) {
      return '/explore';
    }
    return null;
  },
  builder: (context, state) {
    final query = state.uri.queryParameters['q'];
    return AiAssistantScreen(initialQuery: query);
  },
),

这一步非常关键。因为它说明 AI 助手在产品里不是弹窗式实验能力,而是正式页面入口。更重要的是,它还支持 initialQuery 参数,意味着 AI 助手页面不是只能靠用户手打输入开始,而是可以承接外部页面传来的问题。

三、多入口如何把问题带进 AI 助手

食界探味的 AI 助手不是只有一个入口,而是从多个页面都能自然地进入:

搜索页:当搜索无结果时,引导用户用 AI 探索

// app/lib/features/search/screens/search_screen.dart

if (AppConfig.enableAi && _looksLikeNaturalLanguage(state.query))
  _AiSearchHint(
    query: state.query,
    onTap: () => context.push(
      '/ai-assistant?q=${Uri.encodeComponent(state.query)}',
    ),
  ),

这里有个智能判断:_looksLikeNaturalLanguage 检查搜索词是否像自然语言(而不是食材关键词)。如果是"今晚想吃点辣的"这类话,搜索页会提示"试试 AI 探味助手",点击后带着原始 query 跳转到 AI 助手。

菜品详情页:带着"推荐类似吃法"的问题进来

// app/lib/features/dish_detail/screens/dish_detail_screen.dart

onSimilar: () => context.push(
  '/ai-assistant?q=${Uri.encodeComponent(
    "帮我推荐和${dish.name}类似的${dish.ingredientName}吃法"
  )}',
),

用户在看一道菜时,可以点击"类似吃法"按钮,系统会自动构造一个精准的问题带进 AI 助手。这种"带着上下文进入 AI"的体验比"空聊"好得多。

探索页:AI 入口卡片

// app/lib/features/explore/screens/explore_screen.dart

onTap: () => context.push('/ai-assistant'),

探索页有一个 AI 入口卡片,点击后直接进入 AI 助手页面。

四、页面层本身负责的是"对话体验容器"

回到 ai_assistant_screen.dart,你会发现它真正承担的是页面体验组织,而不是 AI 推理本身。

这个页面主要做了几件事:

class _AiAssistantScreenState extends ConsumerState<AiAssistantScreen> {
  final List<_ChatEntry> _history = [];        // 对话历史
  String? _lastStreamingText;                   // 流式输出缓冲
  bool _isSpeaking = false;                     // 语音播报状态

  // 提交问题 → 委托给协调器
  void _handleSubmit(String text) {
    setState(() {
      _history.add(_ChatEntry(isUser: true, text: text));
      _lastStreamingText = null;
    });
    ref.read(aiExploreCoordinatorProvider.notifier).submitQuery(text);
  }

页面上真正的组件层也已经拆出来了:

  • AiInputBar — 底部输入栏(文本 + 语音按钮)

  • AiMessageBubble — 消息气泡(支持流式动画)

  • AiDishCardList — 菜品卡片列表

  • AiDishCard — 单个菜品卡片(可点击跳转详情)

这说明它不是一个把所有逻辑塞进单文件的聊天页,而是一个已经开始组件化的 AI 对话页面。

页面还有一个重要的初始 query 处理逻辑:

// 当有初始查询时自动提交
if (widget.initialQuery != null &&
    widget.initialQuery!.isNotEmpty &&
    !_hasSubmittedInitial) {
  _hasSubmittedInitial = true;
  WidgetsBinding.instance.addPostFrameCallback((_) {
    _handleSubmit(widget.initialQuery!);
  });
}

这段代码让 AI 助手页面从其他页面带着问题进来时,能够自动开始回答,而不是等用户再手动输入一次。

五、AI 页面不是直接调模型,而是先调协调器

在这套结构里,页面真正依赖的是:

final sessionState = ref.watch(aiExploreCoordinatorProvider);
final coordinator = ref.read(aiExploreCoordinatorProvider.notifier);

也就是说,页面层提交问题时,走的是:

  • _handleSubmit(text)

  • coordinator.submitQuery(text)

而不是页面里直接写模型调用、工具执行、状态机处理。这一步的价值很大,因为它把页面展示职责AI 流程编排职责分开了。

六、协调器才是 AI 助手的真正工作台

app/lib/core/ai/ai_explore_coordinator.dart 是这套 AI 页面真正的主心骨。

它负责的事情包括:

class AiExploreCoordinator extends StateNotifier<AiSessionState> {
  // 初始化 Agent(带真实业务工具)
  void _ensureAgent() {
    _agentService.createAgent(
      systemPrompt: _systemPrompt,
      tools: [
        SearchDishesTool(_foodRepository, onDishesFound: _onDishesFound),
        GetDishDetailTool(_foodRepository),
        GetRandomDishTool(_foodRepository, onDishesFound: _onDishesFound),
        GetDishesByIngredientTool(_foodRepository, onDishesFound: _onDishesFound),
      ],
      enableAutoToolExecution: true,
      maxMessages: 30,
    );
  }

协调器把 4 个业务工具注册到了 Agent 里:

工具

功能

SearchDishesTool

根据关键词搜索菜品

GetDishDetailTool

获取某道菜的详细信息

GetRandomDishTool

随机推荐一道菜

GetDishesByIngredientTool

按食材查同食材的其他吃法

协调器的流式对话处理:

Future<void> submitQuery(String text) async {
  state = state.copyWith(
    status: AiSessionStatus.parsing,
    inputText: text,
    streamingText: '',
  );

  _ensureAgent();

  final buffer = StringBuffer();
  await _agentService.chatWithToolsStream(
    message: text,
    onContent: (chunk) {
      buffer.write(chunk);
      state = state.copyWith(
        status: AiSessionStatus.responding,
        streamingText: buffer.toString(),
      );
    },
    onToolCall: (toolCall) {
      state = state.copyWith(status: AiSessionStatus.searching);
    },
    onComplete: (full) {
      state = state.copyWith(
        status: AiSessionStatus.idle,
        streamingText: full,
      );
    },
  );
}

协调器还直接接上了鸿蒙的语音能力:

// 语音输入 → 走鸿蒙 SpeechRecognitionPlugin
Future<void> startVoiceInput() async {
  state = state.copyWith(status: AiSessionStatus.listening);
  final text = await SpeechRecognitionChannel.startListening();
  if (text.isNotEmpty) {
    await submitQuery(text);
  }
}

// TTS 播报 → 走鸿蒙 TextToSpeechPlugin(自动清理 Markdown 格式)
Future<void> speakText(String text) async {
  final cleaned = _stripForTts(text);
  await TextToSpeechChannel.speak(cleaned);
}

这里有个细节:_stripForTts 函数会清理 AI 回复中的 Markdown 格式、emoji、表格符号等,确保鸿蒙 TTS 引擎播报出来的声音是干净的自然语言。

如果只看页面,你会误以为这是一个"聊天 UI"。但只要看到协调器,就会发现它其实是:AI 交互状态机 + 工具调用编排器 + 语音输入输出协调器。

七、AgentService 负责把模型能力收成统一接口

再往下走,AgentService 负责的是更底层的模型交互:

class AgentService {
  AIAgent? _currentAgent;

  AIAgent createAgent({
    required String systemPrompt,
    List<Tool>? tools,
    int maxMessages = 50,
    bool enableAutoToolExecution = false,
  }) {
    final agent = AIAgent(
      provider: _provider,
      config: AIAgentConfig(
        systemPrompt: systemPrompt,
        enableAutoToolExecution: enableAutoToolExecution,
      ),
      memoryManager: ConversationMemory(maxMessages: maxMessages),
    );

    if (tools != null) {
      for (final tool in tools) {
        agent.addTool(tool);
      }
    }

    _currentAgent = agent;
    return agent;
  }

也就是说,页面不直接碰 Provider,协调器也不直接碰底层 Provider。中间先由 AgentService 把 agent 创建、memoryManager、工具注册、流式输出这些底层能力统一收了起来。

八、AI 助手真正的差异化在于"文本 + 菜品卡片"一起出现

这套页面结构特别适合食界探味的原因,不只是它能对话,而是它没有把 AI 回复只做成一段文字。

在协调器里,工具调用拿到的菜品结果会通过回调更新到状态里:

void _onDishesFound(List<Dish> dishes) {
  state = state.copyWith(matchedDishes: dishes);
}

页面层最终通过 AiDishCardList 把这些结果变成真正可点击的菜品卡片。这意味着 AI 在这里承担的并不是"替代页面",而是:生成推荐语义 + 驱动已有业务卡片展示。这比纯聊天页稳得多。

九、语音输入输出为什么也能无缝嵌进页面

这一点也很关键。

协调器已经直接接上了鸿蒙的语音能力:

  • SpeechRecognitionChannel.startListening() → 鸿蒙 speechRecognizer

  • TextToSpeechChannel.speak(...) → 鸿蒙 textToSpeech

所以页面层最终能得到的是:

AiInputBar(
  onSubmit: _handleSubmit,
  onVoiceStart: () => coordinator.startVoiceInput(),
  onVoiceEnd: () => coordinator.stopVoiceInput(),
  isListening: sessionState.status == AiSessionStatus.listening,
)

输入栏的语音按钮采用"按住说话"交互:按下时触发 startVoiceInput(),松开时触发 stopVoiceInput()。鸿蒙侧会自动处理 VAD(语音端点检测),用户停止说话后自动结束识别。

语音播报方面,页面层通过 _toggleSpeak 控制:

void _toggleSpeak(String text) async {
  if (_isSpeaking) {
    await TextToSpeechChannel.stop();  // 鸿蒙 TTS 停止
    setState(() => _isSpeaking = false);
  } else {
    setState(() => _isSpeaking = true);
    await TextToSpeechChannel.speak(text);  // 鸿蒙 TTS 播报
  }
}

更重要的是,页面退出时会自动停止 TTS:

@override
void dispose() {
  if (_isSpeaking) {
    TextToSpeechChannel.stop().catchError((_) {});
  }
  super.dispose();
}

这保证了用户退出 AI 页面后不会出现"声音还在后台播"的问题。

状态机:AI 会话的完整生命周期

食界探味的 AI 助手使用了一套清晰的状态机:

enum AiSessionStatus {
  idle,        // 空闲
  listening,   // 正在语音识别
  parsing,     // 正在理解用户意图
  searching,   // 正在搜索菜品(工具调用中)
  responding,  // 正在流式生成回复
  speaking,    // 正在 TTS 播报
  error,       // 出错
}

状态流转:

idle → listening(用户按住语音按钮)
listening → parsing(语音识别完成,文本提交)
parsing → searching(模型决定调用工具)
searching → responding(工具结果返回,模型生成回复)
responding → idle(回复完成)
idle → speaking(用户点击播报按钮)
speaking → idle(播报完成或手动停止)
任何状态 → error(出错)
error → parsing(用户点击重试)

页面根据当前状态展示不同的 UI:

switch (sessionState.status) {
  case AiSessionStatus.listening:
    return AiMessageBubble(text: '正在聆听...', isStreaming: true);
  case AiSessionStatus.parsing:
    return AiMessageBubble(text: '正在理解你的需求...', isStreaming: true);
  case AiSessionStatus.searching:
    return AiMessageBubble(text: '正在探索全球美食...', isStreaming: true);
  case AiSessionStatus.responding:
    return AiMessageBubble(text: sessionState.streamingText, isStreaming: true);
  default:
    return SizedBox.shrink();
}

关键代码位置

文件

作用

app/lib/app.dart

路由配置,AI 助手入口

app/lib/features/ai_assistant/screens/ai_assistant_screen.dart

AI 对话页面

app/lib/features/ai_assistant/widgets/ai_input_bar.dart

底部输入栏(文本+语音)

app/lib/features/ai_assistant/widgets/ai_message_bubble.dart

消息气泡

app/lib/features/ai_assistant/widgets/ai_dish_card_list.dart

菜品卡片列表

app/lib/core/ai/ai_explore_coordinator.dart

AI 流程编排协调器

app/lib/core/ai/agent_service.dart

模型调用统一封装

app/lib/core/ai/models/ai_session_state.dart

会话状态模型

app/lib/core/ai/tools/

工具层

app/lib/core/platform/speech_recognition_channel.dart

语音识别通道

app/lib/core/platform/text_to_speech_channel.dart

TTS 通道

app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets

鸿蒙语音识别插件

app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets

鸿蒙 TTS 插件

鸿蒙侧与 Flutter 侧的协作关系

从整体架构看,AI 助手的双端协作可以这样理解:

┌─────────────────────────────────────────────────┐
│                   Flutter 侧                      │
│                                                   │
│  AiAssistantScreen (页面层)                       │
│       │                                           │
│       ▼                                           │
│  AiExploreCoordinator (协调器)                    │
│       │          │           │                    │
│       ▼          ▼           ▼                    │
│  AgentService  SpeechChannel  TtsChannel         │
│       │          │           │                    │
│       ▼          │           │                    │
│  工具层/模型层    │           │                    │
│                  │           │                    │
├──────────────────┼───────────┼────────────────────┤
│          MethodChannel       │                    │
│                  │           │                    │
├──────────────────┼───────────┼────────────────────┤
│                   鸿蒙侧                          │
│                  │           │                    │
│     SpeechRecognitionPlugin  TextToSpeechPlugin   │
│                  │           │                    │
│            CoreSpeechKit  CoreSpeechKit           │
└─────────────────────────────────────────────────┘

核心要点:

  1. AI 推理完全在 Flutter 侧完成 — 通过 AgentService 调用云端模型,鸿蒙侧不参与 AI 推理

  2. 语音能力完全由鸿蒙侧提供 — 通过 CoreSpeechKit 的 speechRecognizer 和 textToSpeech

  3. Flutter 侧只看到统一的 Channel 接口 — 不感知底层是鸿蒙还是 Android

  4. 鸿蒙插件需要管理引擎生命周期 — 创建、使用、shutdown,避免资源泄漏

  5. 鸿蒙插件需要处理权限 — 麦克风权限必须在鸿蒙侧动态申请

常见坑

  • 页面里直接调模型,导致 UI、状态和工具调用混成一团 → 一定要抽出协调器

  • AI 回复只做文本,不接业务卡片 → 利用工具回调把菜品数据带回页面层

  • 从别的页面进 AI 助手时,没有设计初始 query 入口 → 用 initialQuery 参数承接上下文

  • 页面退出时不处理 TTS 停止,导致体验很差 → 在 dispose() 里调 TextToSpeechChannel.stop()

  • 鸿蒙引擎不 shutdown,导致内存泄漏 → 每次识别/TTS 完成后必须调 shutdown()

  • 鸿蒙麦克风权限未申请,导致语音识别直接失败 → 在 startListening 前先 requestMicrophonePermission

  • TTS 播报时把 Markdown 格式一起读出来 → 协调器里 _stripForTts 先清理再播报

可复用模板

如果你要在自己的鸿蒙 + Flutter 项目里做类似的 AI 助手嵌入,可以参考这个结构:

页面层(AiAssistantScreen)
  ├─ 输入栏(文本 + 语音按钮)
  ├─ 消息列表(用户消息 + AI 回复 + 菜品卡片)
  └─ 错误提示条

协调器(AiExploreCoordinator)
  ├─ 状态机(idle → parsing → searching → responding → idle)
  ├─ Agent 管理(创建、重置、工具注册)
  ├─ 流式对话(chatWithToolsStream)
  ├─ 语音输入(startVoiceInput → SpeechRecognitionChannel)
  └─ TTS 播报(speakText → TextToSpeechChannel)

模型服务(AgentService)
  ├─ createAgent(systemPrompt + tools)
  ├─ chatWithToolsStream(流式 + 工具回调)
  └─ chatWithTools(非流式)

工具层(Tools)
  ├─ SearchDishesTool
  ├─ GetDishDetailTool
  ├─ GetRandomDishTool
  └─ GetDishesByIngredientTool

鸿蒙原生插件
  ├─ SpeechRecognitionPlugin(CoreSpeechKit speechRecognizer)
  └─ TextToSpeechPlugin(CoreSpeechKit textToSpeech)

Flutter 侧协调器的 Riverpod Provider 模板:

final aiExploreCoordinatorProvider =
    StateNotifierProvider.autoDispose<AiExploreCoordinator, AiSessionState>(
        (ref) {
  final agentService = ref.watch(agentServiceProvider);
  final foodRepo = ref.watch(foodRepositoryProvider);
  return AiExploreCoordinator(
    agentService: agentService,
    foodRepository: foodRepo,
  );
});

页面中使用协调器的模板:

final sessionState = ref.watch(aiExploreCoordinatorProvider);
final coordinator = ref.read(aiExploreCoordinatorProvider.notifier);

// 提交问题
coordinator.submitQuery(text);

// 语音输入
coordinator.startVoiceInput();

// TTS 播报
coordinator.speakText(text);

// 重置会话
coordinator.reset();

本篇总结

食界探味的 AI 助手之所以能自然嵌进鸿蒙 + Flutter 页面里,关键不在"有一个聊天页面",而在于它已经形成了:

  • 鸿蒙原生能力层 — 语音识别和 TTS 通过 CoreSpeechKit 提供,经由 MethodChannel 暴露给 Flutter

  • 正式路由入口 — 支持 initialQuery,可从搜索页、详情页、探索页多入口进入

  • 协调器状态层 — 管理 AI 会话生命周期、工具调用编排、语音输入输出

  • 模型服务层 — AgentService 统一封装模型调用细节

  • 工具调用层 — 4 个业务工具让 AI 能检索真实菜品数据

  • 业务卡片回填层 — AI 推荐直接驱动菜品卡片展示,不只是一段文字

这套结构让 AI 助手不再是孤立演示页,而是真正进入了应用主体验链路——在鸿蒙设备上,用户可以用语音和 AI 聊美食,AI 推荐的菜品卡片可以直接点击查看详情,整个过程流畅自然,鸿蒙原生能力和 Flutter UI 无缝协作。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐