Commit 4bf563598d62aa8de2da611b351442f5d48229fd

Authored by weixin_45529955
1 parent ea66b2f3

安卓微信截图分享到app 缺iOS

... ... @@ -103,6 +103,18 @@
android:host="com.naji.book"
android:path="/book_detail" />
</intent-filter>
<!-- 接收其他App(微信等)分享的图片,用于识别预订信息 -->
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
... ...
... ... @@ -38,6 +38,9 @@ PODS:
- core_location_fluttify (0.0.1):
- Flutter
- foundation_fluttify
- CwlCatchException (2.2.1):
- CwlCatchExceptionSupport (~> 2.2.1)
- CwlCatchExceptionSupport (2.2.1)
- device_info (0.0.1):
- Flutter
- device_utils_my (0.0.1):
... ... @@ -73,6 +76,9 @@ PODS:
- Flutter
- flutter_native_image (0.0.1):
- Flutter
- flutter_open_chinese_convert (0.0.1):
- Flutter
- OpenCC
- flutter_sms (1.1.0):
- Flutter
- flutter_statusbarcolor_ns (0.0.1):
... ... @@ -114,6 +120,7 @@ PODS:
- MTBBarcodeScanner (5.0.11)
- open_filex (0.0.2):
- Flutter
- OpenCC (0.3.3)
- OrderedSet (6.0.3)
- package_info (0.0.1):
- Flutter
... ... @@ -126,6 +133,8 @@ PODS:
- Flutter
- FlutterMacOS
- ReachabilitySwift (5.2.4)
- record_darwin (1.0.0):
- Flutter
- SDWebImage (5.20.0):
- SDWebImage/Core (= 5.20.0)
- SDWebImage/Core (5.20.0)
... ... @@ -139,6 +148,10 @@ PODS:
- FlutterMacOS
- sign_in_with_apple (0.0.1):
- Flutter
- speech_to_text (7.2.0):
- CwlCatchException
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
... ... @@ -161,6 +174,8 @@ PODS:
- webview_flutter (0.0.1):
- Flutter
- WechatOpenSDK-XCFramework (2.0.4)
- whisper_cpp_flutter_plus (0.4.1):
- Flutter
DEPENDENCIES:
- amap_core_fluttify (from `.symlinks/plugins/amap_core_fluttify/ios`)
... ... @@ -185,6 +200,7 @@ DEPENDENCIES:
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_native_contact_picker (from `.symlinks/plugins/flutter_native_contact_picker/ios`)
- flutter_native_image (from `.symlinks/plugins/flutter_native_image/ios`)
- flutter_open_chinese_convert (from `.symlinks/plugins/flutter_open_chinese_convert/ios`)
- flutter_sms (from `.symlinks/plugins/flutter_sms/ios`)
- flutter_statusbarcolor_ns (from `.symlinks/plugins/flutter_statusbarcolor_ns/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
... ... @@ -199,9 +215,11 @@ DEPENDENCIES:
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- record_darwin (from `.symlinks/plugins/record_darwin/ios`)
- sendsms (from `.symlinks/plugins/sendsms/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
- speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- tpns_flutter_plugin (from `.symlinks/plugins/tpns_flutter_plugin/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
... ... @@ -209,6 +227,7 @@ DEPENDENCIES:
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`)
- webview_flutter (from `.symlinks/plugins/webview_flutter/ios`)
- whisper_cpp_flutter_plus (from `.symlinks/plugins/whisper_cpp_flutter_plus/ios`)
SPEC REPOS:
trunk:
... ... @@ -218,9 +237,12 @@ SPEC REPOS:
- AMapSearch-NO-IDFA
- BIJKPlayer
- Bugly
- CwlCatchException
- CwlCatchExceptionSupport
- libwebp
- Mantle
- MTBBarcodeScanner
- OpenCC
- OrderedSet
- ReachabilitySwift
- SDWebImage
... ... @@ -275,6 +297,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_native_contact_picker/ios"
flutter_native_image:
:path: ".symlinks/plugins/flutter_native_image/ios"
flutter_open_chinese_convert:
:path: ".symlinks/plugins/flutter_open_chinese_convert/ios"
flutter_sms:
:path: ".symlinks/plugins/flutter_sms/ios"
flutter_statusbarcolor_ns:
... ... @@ -303,12 +327,16 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/ios"
record_darwin:
:path: ".symlinks/plugins/record_darwin/ios"
sendsms:
:path: ".symlinks/plugins/sendsms/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sign_in_with_apple:
:path: ".symlinks/plugins/sign_in_with_apple/ios"
speech_to_text:
:path: ".symlinks/plugins/speech_to_text/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
tpns_flutter_plugin:
... ... @@ -323,6 +351,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/video_thumbnail/ios"
webview_flutter:
:path: ".symlinks/plugins/webview_flutter/ios"
whisper_cpp_flutter_plus:
:path: ".symlinks/plugins/whisper_cpp_flutter_plus/ios"
SPEC CHECKSUMS:
AMap3DMap-NO-IDFA: 6660036f9a8c28ed3e9037936210121599055a2e
... ... @@ -340,6 +370,8 @@ SPEC CHECKSUMS:
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
contacts_service: 7c2ed10147f55891576c6a582f431e385d8de92d
core_location_fluttify: d76555ed03db792a010b362a52a72134a69fe32b
CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a
CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc
device_info: 52e8c0c9c61def8d0a92bf175f5f500abbea04bc
device_utils_my: de33d0a3965bd76cf7e9fb58b7ea71d698686969
fijkplayer: 2dd31ec312ba554bb6a9163cb2f44cdb528e50da
... ... @@ -353,6 +385,7 @@ SPEC CHECKSUMS:
flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619
flutter_native_contact_picker: 22508d3ab5a583068d4a588cb37c0e0b8764722d
flutter_native_image: c0f200e4bc5da392b15878df38654cc85a78b090
flutter_open_chinese_convert: 8b440f57a1666d3deb57b3c9da6318685a061812
flutter_sms: bb63bc226f41fad2b7cc9f0b1d0c15f7730a8310
flutter_statusbarcolor_ns: 28ea17050f37c962fe05a4722a6d43afc4fca76b
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
... ... @@ -366,17 +399,20 @@ SPEC CHECKSUMS:
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
OpenCC: 290dcf27e18ef926aa4fbc0f0a9c116c36145bc5
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info: cce50adca9873c79f931618469d2114b91d71189
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
record_darwin: fb1f375f1d9603714f55b8708a903bbb91ffdb0a
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
sendsms: 43a465a88ce570d59cc10ef0f3bdb922cc303c7b
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftProtobuf: 4dbaffec76a39a8dc5da23b40af1a5dc01a4c02d
TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
... ... @@ -388,6 +424,7 @@ SPEC CHECKSUMS:
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
webview_flutter: fa13f657fd0a01527ab6ba905b3be0fb3e3bfd4d
WechatOpenSDK-XCFramework: 36fb2bea0754266c17184adf4963d7e6ff98b69f
whisper_cpp_flutter_plus: b268c1770195390aecafa38e7d3b4711ec007c14
PODFILE CHECKSUM: 9435259418d39cbc3076dcc2f688f77abaddd31d
... ...
... ... @@ -355,14 +355,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
... ... @@ -376,14 +372,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
... ...
... ... @@ -37,6 +37,9 @@ import 'package:one_key/provider/upload_img_provider.dart';
import 'package:one_key/route/navigator_util.dart';
import 'package:one_key/utils/utils.dart';
import 'package:provider/provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:one_key/pages/agent/agent_chat_page.dart';
import 'package:one_key/pages/agent/shared_reservation_receiver.dart';
import 'package:tpns_flutter_plugin/tpns_flutter_plugin.dart';
import 'app.dart';
... ... @@ -88,6 +91,7 @@ FutureOr<void> main() async {
// await FlutterDownloader.initialize(debug: true, ignoreSsl: true);
await App.initSp();
_initSharedReservationReceiver();
SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp, DeviceOrientation.portraitDown])
.then((_) {
... ... @@ -333,3 +337,70 @@ class MyAppState extends State<MyApp> {
});
}
}
void _initSharedReservationReceiver() {
try {
ReceiveSharingIntent.instance.getMediaStream().listen(
(List<SharedMediaFile> media) => _handleSharedMedia(media),
onError: (Object error) => debugPrint('分享接收失败: $error'),
);
// 延迟到首帧后再读取初始分享内容,确保 Android Activity 已挂载。
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_handleInitialSharedMedia());
});
} catch (error) {
debugPrint('分享接收初始化失败: $error');
}
}
Future<void> _handleInitialSharedMedia() async {
try {
final List<SharedMediaFile> media =
await ReceiveSharingIntent.instance.getInitialMedia();
if (media.isNotEmpty) {
_handleSharedMedia(media);
}
unawaited(ReceiveSharingIntent.instance.reset());
} catch (error) {
debugPrint('读取初始分享内容失败: $error');
}
}
void _handleSharedMedia(List<SharedMediaFile> media) {
if (media.isEmpty) return;
final SharedMediaFile? image = media
.where((SharedMediaFile file) => file.type == SharedMediaType.image)
.firstOrNull;
if (image == null) return;
SharedReservationReceiver.instance.acceptImage(image.path);
_openAgentChatForSharedImage();
}
bool _agentChatPushPending = false;
int _agentChatPushRetry = 0;
void _openAgentChatForSharedImage() {
final NavigatorState? navigator = App.navigatorKey.currentState;
if (navigator == null) {
// 冷启动时首帧可能尚未构建完成,稍后重试。
if (_agentChatPushRetry < 40) {
_agentChatPushRetry++;
WidgetsBinding.instance.addPostFrameCallback((_) {
_openAgentChatForSharedImage();
});
}
return;
}
_agentChatPushRetry = 0;
if (_agentChatPushPending) return;
_agentChatPushPending = true;
navigator
.push(
MaterialPageRoute<void>(builder: (_) => const AgentChatPage()),
)
.whenComplete(() {
_agentChatPushPending = false;
});
}
... ...
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
... ... @@ -6,9 +7,10 @@ import 'package:image_picker/image_picker.dart';
import 'package:one_key/app.dart';
import 'package:one_key/models/notebook_detail.dart';
import 'package:one_key/pages/agent/agent_chat_store.dart';
import 'package:one_key/pages/agent/baidu_ocr_client.dart';
import 'package:one_key/pages/agent/qwen_ocr_client.dart';
import 'package:one_key/pages/agent/reservation_image_parser.dart';
import 'package:one_key/pages/agent/tencent_asr_client.dart';
import 'package:one_key/pages/agent/shared_reservation_receiver.dart';
import 'package:one_key/pages/agent/paraformer_asr_client.dart';
import 'package:one_key/provider/user_model.dart';
import 'package:one_key/res/colors.dart';
import 'package:one_key/route/navigator_util.dart';
... ... @@ -75,11 +77,8 @@ class AgentChatPage extends StatefulWidget {
class _AgentChatPageState extends State<AgentChatPage> {
static const String _greeting = '你好,我是经营助手。今天想问点什么?比如:“今天营业额多少?”';
// TODO: 正式上线时应由服务端下发密钥,避免客户端泄露。
static const String _tencentSecretId = 'AKID9Vgy8FEC5qSxLcllVCmXzJWaVSI0Z2Nb';
static const String _tencentSecretKey = 'c2c5MTOBo5d9XilhaLzVSG4DE7qRuCJF';
static const String _tencentAppId = '1308375824';
static const String _baiduApiKey = 'ChpdwH1VCou4B7eWF1hBqmn6';
static const String _baiduSecretKey = 'ayognpdpOYkoHQmBxjQJQzvjnSpqLA6w';
static const String _qwenApiKey =
'sk-ws-H.PILIHPE.GQc0.MEUCIFS4lSx9mCvznDxgNHzPthawcyEIlr3W8eTtquLAalb3AiEAivaBkCp122lHoIFBiAeWYaUsIyrVOUbUsqQnPj8ApIs';
static const MethodChannel _speechChannel = MethodChannel('com.naji/book');
final ScrollController _scrollController = ScrollController();
... ... @@ -98,7 +97,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
bool _historyLoading = true;
bool _usingNativeSpeech = false;
bool _usingWhisper = false;
bool _usingTencentAsr = false;
bool _usingCloudAsr = false;
bool _whisperModelReady = false;
bool _voicePressActive = false;
bool _imageProcessing = false;
... ... @@ -114,8 +113,9 @@ class _AgentChatPageState extends State<AgentChatPage> {
WhisperEngine? _whisperEngine;
WhisperStreamTask? _whisperTask;
StreamSubscription<WhisperStreamUpdate>? _whisperUpdateSub;
VoidCallback? _sharedImageListener;
Future<bool>? _whisperPrepareFuture;
TencentAsrSession? _tencentSession;
ParaformerAsrSession? _cloudAsrSession;
bool get _supportsWhisper =>
defaultTargetPlatform == TargetPlatform.android ||
... ... @@ -142,6 +142,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
unawaited(_prewarmWhisperEngine());
}
_loadHistory();
_initSharedReservationListener();
}
@override
... ... @@ -150,8 +151,14 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceTimer?.cancel();
_voiceRequestId++;
_whisperUpdateSub?.cancel();
final VoidCallback? sharedListener = _sharedImageListener;
_sharedImageListener = null;
if (sharedListener != null) {
SharedReservationReceiver.instance.pendingImage
.removeListener(sharedListener);
}
unawaited(_whisperTask?.cancel());
unawaited(_tencentSession?.cancel());
unawaited(_cloudAsrSession?.cancel());
try {
_whisperEngine?.dispose();
} catch (_) {}
... ... @@ -188,8 +195,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
try {
final WhisperModelManager manager =
_whisperModels ??= WhisperModelManager();
final model =
await manager.findCatalogModel(WhisperModelCatalog.base);
final model = await manager.findCatalogModel(WhisperModelCatalog.base);
if (model == null || _whisperEngine != null) return;
final WhisperEngine engine = await WhisperEngine.load(model.path);
if (!mounted) {
... ... @@ -370,7 +376,8 @@ class _AgentChatPageState extends State<AgentChatPage> {
final String trimmed = text.trim();
if (trimmed.isEmpty) return false;
final ReservationImageParseResult result = ReservationImageParser.parse(trimmed);
final ReservationImageParseResult result =
ReservationImageParser.parse(trimmed);
if (!_isReservationText(result)) return false;
unawaited(_showReservationConfirm(_normalizeReservationDate(result)));
... ... @@ -623,8 +630,14 @@ class _AgentChatPageState extends State<AgentChatPage> {
kind: _CardKind.orderList,
title: '桌${table.group(1)} · 近期订单',
items: [
{'name': '#2026091718 · 桌${table.group(1)}', 'status': '已支付 ¥386'},
{'name': '#2026091721 · 桌${table.group(1)}', 'status': '待支付 ¥168'},
{
'name': '#2026091718 · 桌${table.group(1)}',
'status': '已支付 ¥386'
},
{
'name': '#2026091721 · 桌${table.group(1)}',
'status': '待支付 ¥168'
},
{'name': '#2026091723 · 桌${table.group(1)}', 'status': '已支付 ¥96'},
],
),
... ... @@ -840,7 +853,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceTimer?.cancel();
if (_supportsWhisper) {
await _startTencentVoice(requestId);
await _startCloudVoice(requestId);
return;
}
... ... @@ -848,9 +861,9 @@ class _AgentChatPageState extends State<AgentChatPage> {
await _startLegacyVoice();
}
/// 移动端优先使用腾讯云实时语音识别,失败时回退到离线 Whisper。
Future<void> _startTencentVoice(int requestId) async {
_usingTencentAsr = true;
/// 移动端优先使用百炼 Paraformer 实时语音识别,失败时回退到离线 Whisper。
Future<void> _startCloudVoice(int requestId) async {
_usingCloudAsr = true;
_usingWhisper = false;
setState(() {
_voiceStage = _VoiceStage.preparing;
... ... @@ -859,24 +872,22 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceHint = '正在连接云端语音识别…';
});
final TencentAsrSession session = TencentAsrSession(
secretId: _tencentSecretId,
secretKey: _tencentSecretKey,
appId: _tencentAppId,
final ParaformerAsrSession session = ParaformerAsrSession(
apiKey: _qwenApiKey,
onPartial: (String text) {
if (!mounted || requestId != _voiceRequestId) return;
setState(() => _recognizedText = text);
},
);
_tencentSession = session;
_cloudAsrSession = session;
try {
await session.prepare();
} catch (error) {
debugPrint('Tencent ASR prepare failed: $error');
debugPrint('Paraformer ASR prepare failed: $error');
if (!mounted || requestId != _voiceRequestId) return;
_tencentSession = null;
_usingTencentAsr = false;
_cloudAsrSession = null;
_usingCloudAsr = false;
await session.dispose();
await _startWhisperVoice(requestId);
return;
... ... @@ -888,8 +899,8 @@ class _AgentChatPageState extends State<AgentChatPage> {
}
if (!_voicePressActive) {
_tencentSession = null;
_usingTencentAsr = false;
_cloudAsrSession = null;
_usingCloudAsr = false;
await session.cancel();
if (mounted && requestId == _voiceRequestId) {
setState(() {
... ... @@ -904,10 +915,10 @@ class _AgentChatPageState extends State<AgentChatPage> {
try {
await session.startAudio();
} catch (error) {
debugPrint('Tencent ASR start failed: $error');
debugPrint('Paraformer ASR start failed: $error');
if (!mounted || requestId != _voiceRequestId) return;
_tencentSession = null;
_usingTencentAsr = false;
_cloudAsrSession = null;
_usingCloudAsr = false;
await session.dispose();
await _startWhisperVoice(requestId);
return;
... ... @@ -972,20 +983,20 @@ class _AgentChatPageState extends State<AgentChatPage> {
await _startWhisperRecording(requestId);
}
Future<void> _stopTencentVoice(int requestId) async {
Future<void> _stopCloudVoice(int requestId) async {
_voiceTimer?.cancel();
final TencentAsrSession? session = _tencentSession;
final ParaformerAsrSession? session = _cloudAsrSession;
if (session == null) return;
String? text;
try {
text = await session.stop();
} catch (error) {
debugPrint('Tencent ASR stop failed: $error');
debugPrint('Paraformer ASR stop failed: $error');
}
unawaited(session.dispose());
_tencentSession = null;
_usingTencentAsr = false;
_cloudAsrSession = null;
_usingCloudAsr = false;
if (!mounted || requestId != _voiceRequestId) return;
await _finishVoiceWithText(text);
}
... ... @@ -1240,9 +1251,9 @@ class _AgentChatPageState extends State<AgentChatPage> {
if (!mounted) return;
if (_voiceStage != _VoiceStage.recording) return;
if (_usingTencentAsr) {
if (_usingCloudAsr) {
final int requestId = _voiceRequestId;
await _stopTencentVoice(requestId);
await _stopCloudVoice(requestId);
return;
}
... ... @@ -1308,8 +1319,8 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceTimer?.cancel();
_usingNativeSpeech = false;
_usingWhisper = false;
_usingTencentAsr = false;
_tencentSession = null;
_usingCloudAsr = false;
_cloudAsrSession = null;
_whisperUpdateSub?.cancel();
_whisperUpdateSub = null;
_whisperTask = null;
... ... @@ -1344,9 +1355,9 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceRequestId++;
_usingWhisper = false;
_usingNativeSpeech = false;
_usingTencentAsr = false;
unawaited(_tencentSession?.cancel());
_tencentSession = null;
_usingCloudAsr = false;
unawaited(_cloudAsrSession?.cancel());
_cloudAsrSession = null;
_whisperUpdateSub?.cancel();
_whisperUpdateSub = null;
_whisperTask = null;
... ... @@ -1367,10 +1378,10 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceTimer?.cancel();
_voiceRequestId++;
_usingWhisper = false;
if (_usingTencentAsr) {
_usingTencentAsr = false;
unawaited(_tencentSession?.cancel());
_tencentSession = null;
if (_usingCloudAsr) {
_usingCloudAsr = false;
unawaited(_cloudAsrSession?.cancel());
_cloudAsrSession = null;
}
if (!mounted) return;
setState(() {
... ... @@ -1384,10 +1395,10 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceTimer?.cancel();
_voiceRequestId++;
_usingWhisper = false;
if (_usingTencentAsr) {
_usingTencentAsr = false;
final TencentAsrSession? session = _tencentSession;
_tencentSession = null;
if (_usingCloudAsr) {
_usingCloudAsr = false;
final ParaformerAsrSession? session = _cloudAsrSession;
_cloudAsrSession = null;
if (session != null) {
try {
await session.cancel();
... ... @@ -1429,27 +1440,62 @@ class _AgentChatPageState extends State<AgentChatPage> {
Future<void> _pickReservationImage() async {
if (_imageProcessing) return;
setState(() => _imageProcessing = true);
final XFile? file = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: 1600,
imageQuality: 80,
);
if (file == null) return;
await _processReservationImageBytes(await file.readAsBytes());
}
/// 订阅外部分享(如微信截图)进来的图片,自动进入预订识别流程。
void _initSharedReservationListener() {
final SharedReservationReceiver receiver =
SharedReservationReceiver.instance;
_sharedImageListener = () {
final String? path = receiver.pendingImage.value;
if (path == null) return;
unawaited(_processSharedReservationImage(path));
};
receiver.pendingImage.addListener(_sharedImageListener!);
final String? initialPath = receiver.pendingImage.value;
if (initialPath != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_processSharedReservationImage(initialPath));
});
}
}
Future<void> _processSharedReservationImage(String path) async {
if (_imageProcessing) return;
try {
final XFile? file = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: 1600,
imageQuality: 80,
);
if (file == null) return;
final Uint8List bytes = await file.readAsBytes();
final BaiduOcrClient ocr = BaiduOcrClient(
apiKey: _baiduApiKey,
secretKey: _baiduSecretKey,
);
final String text = await ocr.recognize(bytes);
debugPrint('OCR识别成功: ${text.length}字');
final Uint8List bytes = await File(path).readAsBytes();
if (!mounted) return;
await _processReservationImageBytes(bytes);
} catch (error) {
debugPrint('读取分享图片失败: $error');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('读取分享图片失败,请重试')),
);
}
} finally {
SharedReservationReceiver.instance.consumeImage();
}
}
Future<void> _processReservationImageBytes(Uint8List bytes) async {
if (_imageProcessing) return;
setState(() => _imageProcessing = true);
try {
final QwenOcrClient ocr = QwenOcrClient(apiKey: _qwenApiKey);
final ReservationImageParseResult result =
ReservationImageParser.parse(text);
await ocr.parseReservation(bytes);
debugPrint('千问OCR识别成功: ${result.rawText.length}字');
if (!mounted) return;
if (!result.hasAny) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('没有从图片中识别到预订信息')),
... ... @@ -1459,7 +1505,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
setState(() => _imageProcessing = false);
await _showReservationConfirm(_normalizeReservationDate(result));
} on BaiduOcrException catch (error) {
} on QwenOcrException catch (error) {
debugPrint('OCR失败: ${error.toString()}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
... ... @@ -1511,10 +1557,11 @@ class _AgentChatPageState extends State<AgentChatPage> {
if (timeText.isNotEmpty) timeText,
].join(' ');
final DateTime? bookingTime = _parseReservationTime(combined);
DateTime? bookingTime = _parseReservationTime(combined);
if (bookingTime == null || combined.isEmpty) {
return result;
}
bookingTime = _adjustFutureDate(bookingTime, result.rawText);
final String month = bookingTime.month.toString().padLeft(2, '0');
final String day = bookingTime.day.toString().padLeft(2, '0');
... ... @@ -1563,6 +1610,67 @@ class _AgentChatPageState extends State<AgentChatPage> {
NavigatorUtil.goNoteBookChangeFPage(context, detail, '');
}
DateTime _adjustFutureDate(DateTime date, String rawText) {
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
if (!DateTime(date.year, date.month, date.day).isBefore(today)) {
return date;
}
final int? weekday = _weekdayFromText(rawText);
final bool hasMonth =
RegExp(r'\d{1,2}\s*月|\d{4}[-/.]\d{1,2}').hasMatch(rawText);
DateTime? result;
if (weekday != null) {
for (int i = 1; i <= 3660; i++) {
final DateTime cursor = today.add(Duration(days: i));
if (cursor.day == date.day && cursor.weekday == weekday) {
result = cursor;
break;
}
}
} else if (hasMonth) {
DateTime candidate = DateTime(now.year, date.month, date.day);
if (candidate.isBefore(today)) {
candidate = DateTime(now.year + 1, date.month, date.day);
}
result = candidate;
} else {
DateTime candidate = DateTime(now.year, now.month, date.day);
if (candidate.isBefore(today)) {
candidate = DateTime(now.year, now.month + 1, date.day);
}
result = candidate;
}
final DateTime safe = result ?? date;
return DateTime(safe.year, safe.month, safe.day, date.hour, date.minute);
}
int? _weekdayFromText(String text) {
if (RegExp(r'周一').hasMatch(text)) return 1;
const Map<String, int> mapping = <String, int>{
'周二': 2,
'星期二': 2,
'周三': 3,
'星期三': 3,
'周四': 4,
'星期四': 4,
'周五': 5,
'星期五': 5,
'周六': 6,
'星期六': 6,
'周日': 7,
'星期日': 7,
'星期天': 7,
};
for (final MapEntry<String, int> entry in mapping.entries) {
if (text.contains(entry.key)) return entry.value;
}
return null;
}
String _sexCode(String sex) {
switch (sex) {
case '先生':
... ... @@ -1593,8 +1701,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
int.parse(fullDate.group(3)!),
);
} else {
final Match? monthDay =
RegExp(r'(\d{1,2})月(\d{1,2})日').firstMatch(text);
final Match? monthDay = RegExp(r'(\d{1,2})月(\d{1,2})日').firstMatch(text);
if (monthDay != null) {
date = DateTime(
now.year,
... ... @@ -1674,103 +1781,103 @@ class _AgentChatPageState extends State<AgentChatPage> {
statusBarBrightness: Brightness.light,
),
child: Scaffold(
backgroundColor: const Color(0xFFF3F8F8),
appBar: AppBar(
backgroundColor: themeColor,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: true,
title: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'AI 经营助手',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
Text(
_storeName,
style: const TextStyle(
fontSize: 11,
color: Colors.white70,
backgroundColor: const Color(0xFFF3F8F8),
appBar: AppBar(
backgroundColor: themeColor,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: true,
title: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'AI 经营助手',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
),
],
Text(
_storeName,
style: const TextStyle(
fontSize: 11,
color: Colors.white70,
),
),
],
),
),
),
body: Stack(
children: [
Column(
children: [
Expanded(
child: _historyLoading
? const Center(
child: CircularProgressIndicator(
color: MyColours.color_199095,
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
body: Stack(
children: [
Column(
children: [
Expanded(
child: _historyLoading
? const Center(
child: CircularProgressIndicator(
color: MyColours.color_199095,
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
itemCount: _messages.length,
itemBuilder: (_, int index) {
return _MessageBubble(
message: _messages[index],
themeColor: themeColor,
);
},
),
itemCount: _messages.length,
itemBuilder: (_, int index) {
return _MessageBubble(
message: _messages[index],
themeColor: themeColor,
);
},
),
),
_QuickPrompts(
controller: _promptScrollController,
themeColor: themeColor,
onSend: _send,
),
_InputBar(
controller: _inputController,
streaming: _streaming,
themeColor: themeColor,
onSend: _send,
onStop: _stop,
onVoiceLongPressStart: _startVoice,
onVoiceLongPressEnd: _stopVoice,
onVoiceTap: _showVoiceTapHint,
onPickImage: _pickReservationImage,
imageProcessing: _imageProcessing,
),
],
),
if (_voiceStage == _VoiceStage.preparing)
_PreparingVoiceOverlay(
hint: _voiceHint,
progress: _whisperDownloadProgress,
themeColor: themeColor,
onCancel: _cancelVoicePreparation,
),
_QuickPrompts(
controller: _promptScrollController,
if (_voiceStage == _VoiceStage.recording)
_RecordOverlay(
seconds: _voiceSeconds,
themeColor: themeColor,
onSend: _send,
recognizedText: _recognizedText,
onStop: _stopVoice,
onCancel: _cancelVoiceRecording,
),
_InputBar(
controller: _inputController,
streaming: _streaming,
if (_voiceStage == _VoiceStage.confirming)
_ConfirmVoiceBox(
initialText: _confirmText,
themeColor: themeColor,
onSend: _send,
onStop: _stop,
onVoiceLongPressStart: _startVoice,
onVoiceLongPressEnd: _stopVoice,
onVoiceTap: _showVoiceTapHint,
onPickImage: _pickReservationImage,
imageProcessing: _imageProcessing,
onCancel: () {
setState(() => _voiceStage = _VoiceStage.idle);
},
onConfirm: _confirmVoice,
onChanged: (String value) => _confirmText = value,
),
],
),
if (_voiceStage == _VoiceStage.preparing)
_PreparingVoiceOverlay(
hint: _voiceHint,
progress: _whisperDownloadProgress,
themeColor: themeColor,
onCancel: _cancelVoicePreparation,
),
if (_voiceStage == _VoiceStage.recording)
_RecordOverlay(
seconds: _voiceSeconds,
themeColor: themeColor,
recognizedText: _recognizedText,
onStop: _stopVoice,
onCancel: _cancelVoiceRecording,
),
if (_voiceStage == _VoiceStage.confirming)
_ConfirmVoiceBox(
initialText: _confirmText,
themeColor: themeColor,
onCancel: () {
setState(() => _voiceStage = _VoiceStage.idle);
},
onConfirm: _confirmVoice,
onChanged: (String value) => _confirmText = value,
),
],
],
),
),
),
);
);
}
}
... ... @@ -2087,7 +2194,8 @@ class _ReservationConfirmSheetState extends State<_ReservationConfirmSheet> {
_field('预订人', _nameController),
_field('称谓', _sexController),
_field('联系电话', _phoneController, keyboardType: TextInputType.phone),
_field('用餐人数', _partyController, keyboardType: TextInputType.number),
_field('用餐人数', _partyController,
keyboardType: TextInputType.number),
_field('预订日期', _dateController),
_field('用餐时段', _mealController),
_field('预订时间', _timeController),
... ... @@ -2640,7 +2748,8 @@ class _AgentCardDetailPage extends StatelessWidget {
]);
return rows;
case _CardKind.tableList:
final List<_DetailRow> rows = card.items.map((Map<String, dynamic> item) {
final List<_DetailRow> rows =
card.items.map((Map<String, dynamic> item) {
final String rawName = '${item['name'] ?? ''}';
final String tableName = rawName.split(' · ').first.trim();
final String status = '${item['status'] ?? ''}';
... ...
import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// 腾讯云实时语音识别(WebSocket)客户端。
/// 阿里云百炼 Paraformer 实时语音识别(WebSocket)客户端。
///
/// 协议说明:https://cloud.tencent.com/document/product/1093/48982
class TencentAsrClient {
TencentAsrClient({
required this.secretId,
required this.secretKey,
required this.appId,
this.engineModelType = '16k_zh',
/// 协议说明:
/// https://help.aliyun.com/zh/model-studio/websocket-for-paraformer-real-time-service
class ParaformerAsrClient {
ParaformerAsrClient({
required this.apiKey,
this.model = 'paraformer-realtime-v2',
});
final String secretId;
final String secretKey;
final String appId;
final String engineModelType;
/// 统一复用百炼 API Key(与图片识别共用一个 Key)。
final String apiKey;
final String model;
static const int _frameBytes = 6400;
... ... @@ -35,22 +35,25 @@ class TencentAsrClient {
final List<String> _sentences = <String>[];
String _partial = '';
void Function(String text)? _onPartial;
bool _handshakeDone = false;
String _taskId = '';
String _combinedText() => (_sentences.join() + _partial).trim();
/// 建立连接并等待云端握手成功。失败时抛出 [TencentAsrException]。
/// 建立连接、发送 run-task 并等待服务端 task-started。
/// 失败时抛出 [ParaformerAsrException]。
Future<void> connect() async {
final String voiceId = _newVoiceId();
final Uri uri = _buildUri(voiceId);
final WebSocketChannel channel = WebSocketChannel.connect(uri);
_taskId = _newUuidV4();
final WebSocketChannel channel = IOWebSocketChannel.connect(
Uri.parse('wss://dashscope.aliyuncs.com/api-ws/v1/inference'),
headers: <String, dynamic>{'Authorization': 'Bearer $apiKey'},
);
_channel = channel;
try {
await channel.ready.timeout(const Duration(seconds: 8));
} catch (error) {
await _closeSocket();
throw const TencentAsrException(0, '无法连接腾讯云语音服务,请检查网络');
throw const ParaformerAsrException(0, '无法连接百炼语音服务,请检查网络');
}
_handshake = Completer<void>();
... ... @@ -65,19 +68,20 @@ class TencentAsrClient {
},
);
_sendText(jsonEncode(_runTaskPayload()));
try {
await _handshake!.future.timeout(const Duration(seconds: 8));
_handshakeDone = true;
debugPrint('Tencent ASR connected');
debugPrint('Paraformer ASR connected');
} catch (error) {
await _teardown();
if (error is TencentAsrException) rethrow;
throw const TencentAsrException(0, '语音服务握手超时');
if (error is ParaformerAsrException) rethrow;
throw const ParaformerAsrException(0, '语音服务握手超时');
}
}
/// 开始转发 PCM 音频流。按腾讯云协议要求,以 200ms/6400 字节为
/// 一个数据包发送;音频流结束时自动发送 `{"type":"end"}`。
/// 开始转发 PCM 音频流。按协议以 200ms/6400 字节为一个数据包发送;
/// 音频流结束时自动发送 finish-task。
void startForwarding(Stream<Uint8List> audio) {
_audioSub = audio.listen(
(Uint8List chunk) => _pcmBuffer.addAll(chunk),
... ... @@ -109,7 +113,14 @@ class TencentAsrClient {
_pcmBuffer.removeRange(0, length);
_send(frame);
}
_sendText(jsonEncode(<String, String>{'type': 'end'}));
_sendText(jsonEncode(<String, Object>{
'header': <String, Object>{
'action': 'finish-task',
'task_id': _taskId,
'streaming': 'duplex',
},
'payload': <String, Object>{'input': <String, Object>{}},
}));
}
/// 停止后等待最终识别结果。
... ... @@ -120,8 +131,8 @@ class TencentAsrClient {
return await result.future.timeout(const Duration(seconds: 6));
} on TimeoutException {
return _combinedText();
} on TencentAsrException catch (error) {
debugPrint('Tencent ASR error: $error');
} on ParaformerAsrException catch (error) {
debugPrint('Paraformer ASR error: $error');
return null;
} finally {
await _teardown();
... ... @@ -145,48 +156,75 @@ class TencentAsrClient {
_onPartial = callback;
}
Map<String, Object> _runTaskPayload() => <String, Object>{
'header': <String, Object>{
'action': 'run-task',
'task_id': _taskId,
'streaming': 'duplex',
},
'payload': <String, Object>{
'task_group': 'audio',
'task': 'asr',
'function': 'recognition',
'model': model,
'parameters': <String, Object>{
'format': 'pcm',
'sample_rate': 16000,
'language_hints': <String>['zh'],
'disfluency_removal_enabled': false,
'semantic_punctuation_enabled': false,
'max_sentence_silence': 600,
'punctuation_prediction_enabled': true,
'inverse_text_normalization_enabled': true,
},
'input': <String, Object>{},
},
};
void _onMessage(dynamic message) {
if (message is! String) return;
try {
final Map<String, dynamic> json =
jsonDecode(message.toString()) as Map<String, dynamic>;
final int? code = json['code'] as int?;
if (code != null && code != 0) {
_fail(
TencentAsrException(
code,
json['message']?.toString() ?? '语音识别失败',
),
);
return;
}
if (!_handshakeDone) {
_handshakeDone = true;
if (_handshake != null && !_handshake!.isCompleted) {
_handshake!.complete();
}
}
final Map<String, dynamic>? result =
json['result'] as Map<String, dynamic>?;
if (result != null) {
final int? sliceType = result['slice_type'] as int?;
final String text = (result['voice_text_str'] ?? '').toString();
if (sliceType == 2) {
final int index = (result['index'] as int?) ?? _sentences.length;
while (_sentences.length <= index) {
_sentences.add('');
jsonDecode(message) as Map<String, dynamic>;
final Map<String, dynamic>? header =
json['header'] as Map<String, dynamic>?;
final String? event = header?['event'] as String?;
switch (event) {
case 'task-started':
if (_handshake != null && !_handshake!.isCompleted) {
_handshake!.complete();
}
_sentences[index] = text;
_partial = '';
} else if (sliceType == 1) {
_partial = text;
}
_onPartial?.call(_combinedText());
}
if (json['final'] == 1) {
_completeResult();
return;
case 'result-generated':
final Map<String, dynamic>? payload =
json['payload'] as Map<String, dynamic>?;
final Map<String, dynamic>? output =
payload?['output'] as Map<String, dynamic>?;
final Map<String, dynamic>? sentence =
output?['sentence'] as Map<String, dynamic>?;
if (sentence == null) return;
if (sentence['heartbeat'] == true) return;
final String text = (sentence['text'] ?? '').toString();
final bool sentenceEnd = sentence['sentence_end'] == true;
if (sentenceEnd) {
_sentences.add(text);
_partial = '';
} else {
_partial = text;
}
_onPartial?.call(_combinedText());
return;
case 'task-finished':
_completeResult();
return;
case 'task-failed':
_fail(
ParaformerAsrException(
0,
(header?['error_message'] ?? '语音识别失败').toString(),
),
);
return;
}
} catch (_) {
// 忽略无法解析的单条消息。
... ... @@ -243,85 +281,41 @@ class TencentAsrClient {
} catch (_) {}
}
Uri _buildUri(String voiceId) {
final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final Map<String, String> params = <String, String>{
'secretid': secretId,
'timestamp': '$now',
'expired': '${now + 86400}',
'nonce': '${_randomInt(999999999)}',
'engine_model_type': engineModelType,
'voice_id': voiceId,
'voice_format': '1',
'needvad': '1',
'filter_modal': '1',
'filter_punc': '1',
'convert_num_mode': '1',
};
final List<MapEntry<String, String>> sorted = params.entries.toList()
..sort((MapEntry<String, String> a, MapEntry<String, String> b) =>
a.key.compareTo(b.key));
final String canonical = sorted
.map((MapEntry<String, String> e) => '${e.key}=${e.value}')
.join('&');
final String signText = 'asr.cloud.tencent.com/asr/v2/$appId?$canonical';
final List<int> digest = Hmac(sha1, utf8.encode(secretKey))
.convert(utf8.encode(signText))
.bytes;
final String signature = base64.encode(digest);
final String query = sorted
.map((MapEntry<String, String> e) =>
'${e.key}=${Uri.encodeQueryComponent(e.value)}')
.join('&');
return Uri.parse(
'wss://asr.cloud.tencent.com/asr/v2/$appId?$query'
'&signature=${Uri.encodeQueryComponent(signature)}',
);
}
String _newVoiceId() {
final int now = DateTime.now().microsecondsSinceEpoch;
final int rand = _randomInt(0x7fffffff);
return 'voice_${now}_$rand';
}
int _randomInt(int max) {
final int seed = DateTime.now().microsecondsSinceEpoch;
return (seed % max).abs() + 1;
String _newUuidV4() {
final Random random = Random.secure();
final List<int> bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final String hex =
bytes.map((int byte) => byte.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-'
'${hex.substring(20)}';
}
}
class TencentAsrException implements Exception {
const TencentAsrException(this.code, this.message);
class ParaformerAsrException implements Exception {
const ParaformerAsrException(this.code, this.message);
final int code;
final String message;
@override
String toString() => 'TencentAsrException($code, $message)';
String toString() => 'ParaformerAsrException($code, $message)';
}
/// 负责录音并把 PCM 流交给 [TencentAsrClient] 的会话封装。
class TencentAsrSession {
TencentAsrSession({
required this.secretId,
required this.secretKey,
required this.appId,
/// 负责录音并把 PCM 流交给 [ParaformerAsrClient] 的会话封装。
class ParaformerAsrSession {
ParaformerAsrSession({
required this.apiKey,
this.onPartial,
});
final String secretId;
final String secretKey;
final String appId;
final String apiKey;
final void Function(String text)? onPartial;
final AudioRecorder _recorder = AudioRecorder();
TencentAsrClient? _client;
ParaformerAsrClient? _client;
bool _recording = false;
/// 检查权限、建立连接并完成云端握手,此时尚未开始录音。
... ... @@ -329,23 +323,19 @@ class TencentAsrSession {
if (!await _recorder.hasPermission()) {
final PermissionStatus status = await Permission.microphone.request();
if (!status.isGranted) {
throw const TencentAsrException(0, '未获得麦克风权限');
throw const ParaformerAsrException(0, '未获得麦克风权限');
}
}
final TencentAsrClient client = TencentAsrClient(
secretId: secretId,
secretKey: secretKey,
appId: appId,
);
final ParaformerAsrClient client = ParaformerAsrClient(apiKey: apiKey);
_client = client;
await client.connect();
}
/// 开始录音并发送音频。
Future<void> startAudio() async {
final TencentAsrClient? client = _client;
final ParaformerAsrClient? client = _client;
if (client == null) {
throw const TencentAsrException(0, '语音会话未建立');
throw const ParaformerAsrException(0, '语音会话未建立');
}
final Stream<Uint8List> audio = await _recorder.startStream(
... ... @@ -379,7 +369,7 @@ class TencentAsrSession {
try {
await _recorder.stop();
} catch (_) {}
final TencentAsrClient? client = _client;
final ParaformerAsrClient? client = _client;
_client = null;
try {
await client?.cancel();
... ...
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:one_key/pages/agent/reservation_image_parser.dart';
/// 阿里云百炼(DashScope)千问视觉模型客户端。
///
/// 使用 OpenAI 兼容模式,让 qwen-vl-plus 直接从图片中提取预订字段,
/// 并返回结构化 JSON,匹配 [ReservationImageParseResult] 的字段。
class QwenOcrClient {
QwenOcrClient({
required this.apiKey,
this.model = 'qwen-vl-plus',
});
final String apiKey;
final String model;
static const String _endpoint =
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
final Dio _dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 60),
responseType: ResponseType.json,
),
);
String _systemPrompt() {
final DateTime now = DateTime.now();
final String today = '${now.year}-${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
return '今天是 $today。'
'你是餐厅预订信息提取助手。请仔细识别图片中的文字,并从微信聊天记录、'
'预订截图或手写信息中提取餐厅预订字段。'
'只输出一个 JSON 对象,不要输出任何解释或 Markdown 代码块标记。\n\n'
'JSON 字段说明:\n'
'- name: 预订人姓名,去掉“先生/女士”等称谓\n'
'- sex: 称谓,只能是 先生、女士、公司、贵宾 之一,没有则为空字符串\n'
'- phone: 手机号,没有则为空字符串\n'
'- party: 用餐人数,纯数字字符串,没有则为空字符串\n'
'- date: 预订日期,格式 YYYY-MM-DD;必须是今天或未来日期;只有月日或星期时,按今天 $today 推断为下一个未来日期并补全年份\n'
'- meal: 用餐时段,只能是 早餐、午餐、晚餐 之一;根据时间和上下文推断,没有则为空字符串\n'
'- time: 到店时间,格式 HH:mm,没有则为空字符串\n'
'- table: 桌位号或包间号,没有则为空字符串\n'
'- remark: 备注/特殊要求,没有则为空字符串\n'
'- rawText: 图片中识别出的完整文字,按原顺序用换行拼接\n\n'
'输出示例:\n'
'{"name":"王先生","sex":"先生","phone":"13800138000",'
'"party":"8","date":"$today","meal":"晚餐",'
'"time":"18:30","table":"A8","remark":"靠窗",'
'"rawText":"王先生 13800138000\\n9月25日晚餐 8人"}';
}
/// 从图片中提取预订字段;结构化识别失败时自动降级为正则解析。
Future<ReservationImageParseResult> parseReservation(
Uint8List imageBytes,
) async {
if (imageBytes.isEmpty) {
throw const QwenOcrException(0, '图片内容为空');
}
final String content = await _chat(imageBytes);
final Map<String, dynamic>? json = _parseJsonObject(content);
final ReservationImageParseResult parsed =
ReservationImageParser.parse(content);
if (json == null) {
return parsed;
}
String field(String key) {
final dynamic value = json[key];
if (value == null) return '';
return value.toString().trim();
}
final String rawText =
field('rawText').isNotEmpty ? field('rawText') : content;
final ReservationImageParseResult regexParsed =
ReservationImageParser.parse(rawText);
String firstNonEmpty(String primary, String fallback) {
return primary.isNotEmpty ? primary : fallback;
}
return ReservationImageParseResult(
name: firstNonEmpty(field('name'), regexParsed.name),
sex: _normalizeSex(firstNonEmpty(field('sex'), regexParsed.sex)),
phone: firstNonEmpty(field('phone'), regexParsed.phone),
party: firstNonEmpty(field('party'), regexParsed.party),
date: firstNonEmpty(field('date'), regexParsed.date),
meal: _normalizeMeal(firstNonEmpty(field('meal'), regexParsed.meal)),
time: firstNonEmpty(field('time'), regexParsed.time),
table: firstNonEmpty(field('table'), regexParsed.table),
remark: firstNonEmpty(field('remark'), regexParsed.remark),
rawText: rawText,
);
}
Future<String> _chat(Uint8List imageBytes) async {
final String base64 = base64Encode(imageBytes);
final String mime = _inferMimeType(imageBytes);
final Response<Map<String, dynamic>> response;
try {
response = await _dio.post<Map<String, dynamic>>(
_endpoint,
data: <String, dynamic>{
'model': model,
'temperature': 0.1,
'messages': <Map<String, dynamic>>[
<String, dynamic>{
'role': 'system',
'content': _systemPrompt(),
},
<String, dynamic>{
'role': 'user',
'content': <Map<String, dynamic>>[
<String, dynamic>{
'type': 'image_url',
'image_url': <String, String>{
'url': 'data:$mime;base64,$base64',
},
},
const <String, String>{
'type': 'text',
'text': '请提取图片中的餐厅预订信息并返回 JSON。',
},
],
},
],
},
options: Options(
headers: <String, String>{
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
),
);
} on DioException catch (error) {
throw QwenOcrException(
error.response?.statusCode ?? 0,
_friendlyDioError(error),
);
}
final Map<String, dynamic>? data = response.data;
if (data == null) {
throw const QwenOcrException(0, '图片识别返回数据异常');
}
final dynamic error = data['error'];
if (error is Map) {
throw QwenOcrException(
error['code']?.toString() ?? '0',
error['message']?.toString() ?? '图片识别失败',
);
}
final List<dynamic>? choices = data['choices'] as List<dynamic>?;
final dynamic message =
choices != null && choices.isNotEmpty
? (choices.first as Map<String, dynamic>)['message']
: null;
if (message is! Map) {
throw const QwenOcrException(0, '图片识别未返回有效内容');
}
final dynamic content = message['content'];
if (content is String) {
return content.trim();
}
if (content is List) {
final StringBuffer buffer = StringBuffer();
for (final dynamic item in content) {
if (item is Map) {
final dynamic text = item['text'];
if (text != null) {
buffer.write(text.toString());
}
}
}
return buffer.toString().trim();
}
return '';
}
Map<String, dynamic>? _parseJsonObject(String content) {
final String trimmed = content.trim();
if (trimmed.isEmpty) return null;
final int start = trimmed.indexOf('{');
final int end = trimmed.lastIndexOf('}');
if (start < 0 || end <= start) return null;
try {
final dynamic decoded = jsonDecode(trimmed.substring(start, end + 1));
if (decoded is Map<String, dynamic>) {
return decoded;
}
} catch (_) {
return null;
}
return null;
}
String _normalizeSex(String value) {
if (value == '男') return '先生';
if (value == '女' || value == '小姐') return '女士';
return value;
}
String _normalizeMeal(String value) {
if (value.contains('早') || value.contains('上午') || value.contains('早上')) {
return '早餐';
}
if (value.contains('晚') ||
value.contains('夜') ||
value.contains('宵') ||
value.contains('下午')) {
return '晚餐';
}
if (value.contains('午') || value.contains('中')) {
return '午餐';
}
return value;
}
String _friendlyDioError(DioException error) {
final int? statusCode = error.response?.statusCode;
if (statusCode == 401) return '千问 API Key 无效,请检查百炼密钥';
if (statusCode == 429) return '请求太频繁或额度不足,请稍后重试';
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return '识别请求超时,请检查网络后重试';
}
return '图片识别网络请求失败,请检查网络';
}
String _inferMimeType(Uint8List bytes) {
if (bytes.length >= 3 &&
bytes[0] == 0xFF &&
bytes[1] == 0xD8 &&
bytes[2] == 0xFF) {
return 'image/jpeg';
}
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return 'image/png';
}
if (bytes.length >= 6 &&
bytes[0] == 0x47 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46 &&
bytes[3] == 0x38) {
return 'image/gif';
}
if (bytes.length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D) {
return 'image/bmp';
}
return 'image/jpeg';
}
}
class QwenOcrException implements Exception {
const QwenOcrException(this.code, this.message);
final Object code;
final String message;
@override
String toString() => 'QwenOcrException($code, $message)';
}
... ...
import 'package:flutter/foundation.dart';
/// 承接从其他 App(如微信)分享进来的预订截图。
///
/// 分享回调把图片文件路径写入 [pendingImage],经营助手页监听后
/// 自动走「识别预订信息 → 确认预订」流程。
class SharedReservationReceiver {
SharedReservationReceiver._();
static final SharedReservationReceiver instance =
SharedReservationReceiver._();
/// 待处理的分享图片路径;为 null 表示当前没有待识别图片。
final ValueNotifier<String?> pendingImage = ValueNotifier<String?>(null);
/// 设置待识别图片,并通知监听者。
void acceptImage(String path) {
pendingImage.value = path;
}
/// 标记该图片已被消费。
void consumeImage() {
if (pendingImage.value != null) {
pendingImage.value = null;
}
}
}
... ...
... ... @@ -1590,6 +1590,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.3"
receive_sharing_intent:
dependency: "direct main"
description:
name: receive_sharing_intent
sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.8.1"
record:
dependency: "direct main"
description:
... ...
... ... @@ -74,6 +74,8 @@ dependencies:
cached_network_image: ^3.4.1
# websocket
web_socket_channel: ^2.4.0
# 接收外部App分享(微信截图等)
receive_sharing_intent: ^1.8.1
# sqlite数据库
sqflite: ^2.2.8+4
# Bugly
... ...