适合谁看
想在鸿蒙端把 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); }关键流程:
权限申请— 鸿蒙必须先动态申请麦克风权限:
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 ); }创建识别引擎— 使用在线模式,语言设为
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); } }); }); }开始识别— 设置监听器后发起识别:
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); }结果回传— 识别到最终结果时,通过 MethodChannel 把文本传回 Flutter:
onResult: (sessionId, result) => { if (result.isLast && this.pendingResult) { this.pendingResult.success(result.result); // 回传给 Flutter this.pendingResult = null; this.shutdownEngine(); } }引擎清理— 识别完成后立即 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 里:
工具 | 功能 |
|---|---|
| 根据关键词搜索菜品 |
| 获取某道菜的详细信息 |
| 随机推荐一道菜 |
| 按食材查同食材的其他吃法 |
协调器的流式对话处理:
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()→ 鸿蒙speechRecognizerTextToSpeechChannel.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(); }关键代码位置
文件 | 作用 |
|---|---|
| 路由配置,AI 助手入口 |
| AI 对话页面 |
| 底部输入栏(文本+语音) |
| 消息气泡 |
| 菜品卡片列表 |
| AI 流程编排协调器 |
| 模型调用统一封装 |
| 会话状态模型 |
| 工具层 |
| 语音识别通道 |
| TTS 通道 |
| 鸿蒙语音识别插件 |
| 鸿蒙 TTS 插件 |
鸿蒙侧与 Flutter 侧的协作关系
从整体架构看,AI 助手的双端协作可以这样理解:
┌─────────────────────────────────────────────────┐ │ Flutter 侧 │ │ │ │ AiAssistantScreen (页面层) │ │ │ │ │ ▼ │ │ AiExploreCoordinator (协调器) │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ AgentService SpeechChannel TtsChannel │ │ │ │ │ │ │ ▼ │ │ │ │ 工具层/模型层 │ │ │ │ │ │ │ ├──────────────────┼───────────┼────────────────────┤ │ MethodChannel │ │ │ │ │ │ ├──────────────────┼───────────┼────────────────────┤ │ 鸿蒙侧 │ │ │ │ │ │ SpeechRecognitionPlugin TextToSpeechPlugin │ │ │ │ │ │ CoreSpeechKit CoreSpeechKit │ └─────────────────────────────────────────────────┘核心要点:
AI 推理完全在 Flutter 侧完成— 通过 AgentService 调用云端模型,鸿蒙侧不参与 AI 推理
语音能力完全由鸿蒙侧提供— 通过 CoreSpeechKit 的 speechRecognizer 和 textToSpeech
Flutter 侧只看到统一的 Channel 接口— 不感知底层是鸿蒙还是 Android
鸿蒙插件需要管理引擎生命周期— 创建、使用、shutdown,避免资源泄漏
鸿蒙插件需要处理权限— 麦克风权限必须在鸿蒙侧动态申请
常见坑
页面里直接调模型,导致 UI、状态和工具调用混成一团 → 一定要抽出协调器
AI 回复只做文本,不接业务卡片→ 利用工具回调把菜品数据带回页面层
从别的页面进 AI 助手时,没有设计初始 query 入口→ 用
initialQuery参数承接上下文页面退出时不处理 TTS 停止,导致体验很差 → 在
dispose()里调TextToSpeechChannel.stop()鸿蒙引擎不 shutdown,导致内存泄漏 → 每次识别/TTS 完成后必须调
shutdown()鸿蒙麦克风权限未申请,导致语音识别直接失败 → 在
startListening前先requestMicrophonePermissionTTS 播报时把 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 无缝协作。