Showing
10 changed files
with
837 additions
and
308 deletions
| @@ -103,6 +103,18 @@ | @@ -103,6 +103,18 @@ | ||
| 103 | android:host="com.naji.book" | 103 | android:host="com.naji.book" |
| 104 | android:path="/book_detail" /> | 104 | android:path="/book_detail" /> |
| 105 | </intent-filter> | 105 | </intent-filter> |
| 106 | + | ||
| 107 | + <!-- 接收其他App(微信等)分享的图片,用于识别预订信息 --> | ||
| 108 | + <intent-filter> | ||
| 109 | + <action android:name="android.intent.action.SEND"/> | ||
| 110 | + <category android:name="android.intent.category.DEFAULT"/> | ||
| 111 | + <data android:mimeType="image/*"/> | ||
| 112 | + </intent-filter> | ||
| 113 | + <intent-filter> | ||
| 114 | + <action android:name="android.intent.action.SEND_MULTIPLE"/> | ||
| 115 | + <category android:name="android.intent.category.DEFAULT"/> | ||
| 116 | + <data android:mimeType="image/*"/> | ||
| 117 | + </intent-filter> | ||
| 106 | </activity> | 118 | </activity> |
| 107 | 119 | ||
| 108 | <!-- Don't delete the meta-data below. | 120 | <!-- Don't delete the meta-data below. |
| @@ -38,6 +38,9 @@ PODS: | @@ -38,6 +38,9 @@ PODS: | ||
| 38 | - core_location_fluttify (0.0.1): | 38 | - core_location_fluttify (0.0.1): |
| 39 | - Flutter | 39 | - Flutter |
| 40 | - foundation_fluttify | 40 | - foundation_fluttify |
| 41 | + - CwlCatchException (2.2.1): | ||
| 42 | + - CwlCatchExceptionSupport (~> 2.2.1) | ||
| 43 | + - CwlCatchExceptionSupport (2.2.1) | ||
| 41 | - device_info (0.0.1): | 44 | - device_info (0.0.1): |
| 42 | - Flutter | 45 | - Flutter |
| 43 | - device_utils_my (0.0.1): | 46 | - device_utils_my (0.0.1): |
| @@ -73,6 +76,9 @@ PODS: | @@ -73,6 +76,9 @@ PODS: | ||
| 73 | - Flutter | 76 | - Flutter |
| 74 | - flutter_native_image (0.0.1): | 77 | - flutter_native_image (0.0.1): |
| 75 | - Flutter | 78 | - Flutter |
| 79 | + - flutter_open_chinese_convert (0.0.1): | ||
| 80 | + - Flutter | ||
| 81 | + - OpenCC | ||
| 76 | - flutter_sms (1.1.0): | 82 | - flutter_sms (1.1.0): |
| 77 | - Flutter | 83 | - Flutter |
| 78 | - flutter_statusbarcolor_ns (0.0.1): | 84 | - flutter_statusbarcolor_ns (0.0.1): |
| @@ -114,6 +120,7 @@ PODS: | @@ -114,6 +120,7 @@ PODS: | ||
| 114 | - MTBBarcodeScanner (5.0.11) | 120 | - MTBBarcodeScanner (5.0.11) |
| 115 | - open_filex (0.0.2): | 121 | - open_filex (0.0.2): |
| 116 | - Flutter | 122 | - Flutter |
| 123 | + - OpenCC (0.3.3) | ||
| 117 | - OrderedSet (6.0.3) | 124 | - OrderedSet (6.0.3) |
| 118 | - package_info (0.0.1): | 125 | - package_info (0.0.1): |
| 119 | - Flutter | 126 | - Flutter |
| @@ -126,6 +133,8 @@ PODS: | @@ -126,6 +133,8 @@ PODS: | ||
| 126 | - Flutter | 133 | - Flutter |
| 127 | - FlutterMacOS | 134 | - FlutterMacOS |
| 128 | - ReachabilitySwift (5.2.4) | 135 | - ReachabilitySwift (5.2.4) |
| 136 | + - record_darwin (1.0.0): | ||
| 137 | + - Flutter | ||
| 129 | - SDWebImage (5.20.0): | 138 | - SDWebImage (5.20.0): |
| 130 | - SDWebImage/Core (= 5.20.0) | 139 | - SDWebImage/Core (= 5.20.0) |
| 131 | - SDWebImage/Core (5.20.0) | 140 | - SDWebImage/Core (5.20.0) |
| @@ -139,6 +148,10 @@ PODS: | @@ -139,6 +148,10 @@ PODS: | ||
| 139 | - FlutterMacOS | 148 | - FlutterMacOS |
| 140 | - sign_in_with_apple (0.0.1): | 149 | - sign_in_with_apple (0.0.1): |
| 141 | - Flutter | 150 | - Flutter |
| 151 | + - speech_to_text (7.2.0): | ||
| 152 | + - CwlCatchException | ||
| 153 | + - Flutter | ||
| 154 | + - FlutterMacOS | ||
| 142 | - sqflite_darwin (0.0.4): | 155 | - sqflite_darwin (0.0.4): |
| 143 | - Flutter | 156 | - Flutter |
| 144 | - FlutterMacOS | 157 | - FlutterMacOS |
| @@ -161,6 +174,8 @@ PODS: | @@ -161,6 +174,8 @@ PODS: | ||
| 161 | - webview_flutter (0.0.1): | 174 | - webview_flutter (0.0.1): |
| 162 | - Flutter | 175 | - Flutter |
| 163 | - WechatOpenSDK-XCFramework (2.0.4) | 176 | - WechatOpenSDK-XCFramework (2.0.4) |
| 177 | + - whisper_cpp_flutter_plus (0.4.1): | ||
| 178 | + - Flutter | ||
| 164 | 179 | ||
| 165 | DEPENDENCIES: | 180 | DEPENDENCIES: |
| 166 | - amap_core_fluttify (from `.symlinks/plugins/amap_core_fluttify/ios`) | 181 | - amap_core_fluttify (from `.symlinks/plugins/amap_core_fluttify/ios`) |
| @@ -185,6 +200,7 @@ DEPENDENCIES: | @@ -185,6 +200,7 @@ DEPENDENCIES: | ||
| 185 | - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) | 200 | - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) |
| 186 | - flutter_native_contact_picker (from `.symlinks/plugins/flutter_native_contact_picker/ios`) | 201 | - flutter_native_contact_picker (from `.symlinks/plugins/flutter_native_contact_picker/ios`) |
| 187 | - flutter_native_image (from `.symlinks/plugins/flutter_native_image/ios`) | 202 | - flutter_native_image (from `.symlinks/plugins/flutter_native_image/ios`) |
| 203 | + - flutter_open_chinese_convert (from `.symlinks/plugins/flutter_open_chinese_convert/ios`) | ||
| 188 | - flutter_sms (from `.symlinks/plugins/flutter_sms/ios`) | 204 | - flutter_sms (from `.symlinks/plugins/flutter_sms/ios`) |
| 189 | - flutter_statusbarcolor_ns (from `.symlinks/plugins/flutter_statusbarcolor_ns/ios`) | 205 | - flutter_statusbarcolor_ns (from `.symlinks/plugins/flutter_statusbarcolor_ns/ios`) |
| 190 | - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) | 206 | - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) |
| @@ -199,9 +215,11 @@ DEPENDENCIES: | @@ -199,9 +215,11 @@ DEPENDENCIES: | ||
| 199 | - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) | 215 | - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) |
| 200 | - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) | 216 | - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) |
| 201 | - photo_manager (from `.symlinks/plugins/photo_manager/ios`) | 217 | - photo_manager (from `.symlinks/plugins/photo_manager/ios`) |
| 218 | + - record_darwin (from `.symlinks/plugins/record_darwin/ios`) | ||
| 202 | - sendsms (from `.symlinks/plugins/sendsms/ios`) | 219 | - sendsms (from `.symlinks/plugins/sendsms/ios`) |
| 203 | - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) | 220 | - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) |
| 204 | - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) | 221 | - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) |
| 222 | + - speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`) | ||
| 205 | - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) | 223 | - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) |
| 206 | - tpns_flutter_plugin (from `.symlinks/plugins/tpns_flutter_plugin/ios`) | 224 | - tpns_flutter_plugin (from `.symlinks/plugins/tpns_flutter_plugin/ios`) |
| 207 | - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) | 225 | - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) |
| @@ -209,6 +227,7 @@ DEPENDENCIES: | @@ -209,6 +227,7 @@ DEPENDENCIES: | ||
| 209 | - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) | 227 | - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) |
| 210 | - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) | 228 | - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) |
| 211 | - webview_flutter (from `.symlinks/plugins/webview_flutter/ios`) | 229 | - webview_flutter (from `.symlinks/plugins/webview_flutter/ios`) |
| 230 | + - whisper_cpp_flutter_plus (from `.symlinks/plugins/whisper_cpp_flutter_plus/ios`) | ||
| 212 | 231 | ||
| 213 | SPEC REPOS: | 232 | SPEC REPOS: |
| 214 | trunk: | 233 | trunk: |
| @@ -218,9 +237,12 @@ SPEC REPOS: | @@ -218,9 +237,12 @@ SPEC REPOS: | ||
| 218 | - AMapSearch-NO-IDFA | 237 | - AMapSearch-NO-IDFA |
| 219 | - BIJKPlayer | 238 | - BIJKPlayer |
| 220 | - Bugly | 239 | - Bugly |
| 240 | + - CwlCatchException | ||
| 241 | + - CwlCatchExceptionSupport | ||
| 221 | - libwebp | 242 | - libwebp |
| 222 | - Mantle | 243 | - Mantle |
| 223 | - MTBBarcodeScanner | 244 | - MTBBarcodeScanner |
| 245 | + - OpenCC | ||
| 224 | - OrderedSet | 246 | - OrderedSet |
| 225 | - ReachabilitySwift | 247 | - ReachabilitySwift |
| 226 | - SDWebImage | 248 | - SDWebImage |
| @@ -275,6 +297,8 @@ EXTERNAL SOURCES: | @@ -275,6 +297,8 @@ EXTERNAL SOURCES: | ||
| 275 | :path: ".symlinks/plugins/flutter_native_contact_picker/ios" | 297 | :path: ".symlinks/plugins/flutter_native_contact_picker/ios" |
| 276 | flutter_native_image: | 298 | flutter_native_image: |
| 277 | :path: ".symlinks/plugins/flutter_native_image/ios" | 299 | :path: ".symlinks/plugins/flutter_native_image/ios" |
| 300 | + flutter_open_chinese_convert: | ||
| 301 | + :path: ".symlinks/plugins/flutter_open_chinese_convert/ios" | ||
| 278 | flutter_sms: | 302 | flutter_sms: |
| 279 | :path: ".symlinks/plugins/flutter_sms/ios" | 303 | :path: ".symlinks/plugins/flutter_sms/ios" |
| 280 | flutter_statusbarcolor_ns: | 304 | flutter_statusbarcolor_ns: |
| @@ -303,12 +327,16 @@ EXTERNAL SOURCES: | @@ -303,12 +327,16 @@ EXTERNAL SOURCES: | ||
| 303 | :path: ".symlinks/plugins/permission_handler_apple/ios" | 327 | :path: ".symlinks/plugins/permission_handler_apple/ios" |
| 304 | photo_manager: | 328 | photo_manager: |
| 305 | :path: ".symlinks/plugins/photo_manager/ios" | 329 | :path: ".symlinks/plugins/photo_manager/ios" |
| 330 | + record_darwin: | ||
| 331 | + :path: ".symlinks/plugins/record_darwin/ios" | ||
| 306 | sendsms: | 332 | sendsms: |
| 307 | :path: ".symlinks/plugins/sendsms/ios" | 333 | :path: ".symlinks/plugins/sendsms/ios" |
| 308 | shared_preferences_foundation: | 334 | shared_preferences_foundation: |
| 309 | :path: ".symlinks/plugins/shared_preferences_foundation/darwin" | 335 | :path: ".symlinks/plugins/shared_preferences_foundation/darwin" |
| 310 | sign_in_with_apple: | 336 | sign_in_with_apple: |
| 311 | :path: ".symlinks/plugins/sign_in_with_apple/ios" | 337 | :path: ".symlinks/plugins/sign_in_with_apple/ios" |
| 338 | + speech_to_text: | ||
| 339 | + :path: ".symlinks/plugins/speech_to_text/darwin" | ||
| 312 | sqflite_darwin: | 340 | sqflite_darwin: |
| 313 | :path: ".symlinks/plugins/sqflite_darwin/darwin" | 341 | :path: ".symlinks/plugins/sqflite_darwin/darwin" |
| 314 | tpns_flutter_plugin: | 342 | tpns_flutter_plugin: |
| @@ -323,6 +351,8 @@ EXTERNAL SOURCES: | @@ -323,6 +351,8 @@ EXTERNAL SOURCES: | ||
| 323 | :path: ".symlinks/plugins/video_thumbnail/ios" | 351 | :path: ".symlinks/plugins/video_thumbnail/ios" |
| 324 | webview_flutter: | 352 | webview_flutter: |
| 325 | :path: ".symlinks/plugins/webview_flutter/ios" | 353 | :path: ".symlinks/plugins/webview_flutter/ios" |
| 354 | + whisper_cpp_flutter_plus: | ||
| 355 | + :path: ".symlinks/plugins/whisper_cpp_flutter_plus/ios" | ||
| 326 | 356 | ||
| 327 | SPEC CHECKSUMS: | 357 | SPEC CHECKSUMS: |
| 328 | AMap3DMap-NO-IDFA: 6660036f9a8c28ed3e9037936210121599055a2e | 358 | AMap3DMap-NO-IDFA: 6660036f9a8c28ed3e9037936210121599055a2e |
| @@ -340,6 +370,8 @@ SPEC CHECKSUMS: | @@ -340,6 +370,8 @@ SPEC CHECKSUMS: | ||
| 340 | connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf | 370 | connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf |
| 341 | contacts_service: 7c2ed10147f55891576c6a582f431e385d8de92d | 371 | contacts_service: 7c2ed10147f55891576c6a582f431e385d8de92d |
| 342 | core_location_fluttify: d76555ed03db792a010b362a52a72134a69fe32b | 372 | core_location_fluttify: d76555ed03db792a010b362a52a72134a69fe32b |
| 373 | + CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a | ||
| 374 | + CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc | ||
| 343 | device_info: 52e8c0c9c61def8d0a92bf175f5f500abbea04bc | 375 | device_info: 52e8c0c9c61def8d0a92bf175f5f500abbea04bc |
| 344 | device_utils_my: de33d0a3965bd76cf7e9fb58b7ea71d698686969 | 376 | device_utils_my: de33d0a3965bd76cf7e9fb58b7ea71d698686969 |
| 345 | fijkplayer: 2dd31ec312ba554bb6a9163cb2f44cdb528e50da | 377 | fijkplayer: 2dd31ec312ba554bb6a9163cb2f44cdb528e50da |
| @@ -353,6 +385,7 @@ SPEC CHECKSUMS: | @@ -353,6 +385,7 @@ SPEC CHECKSUMS: | ||
| 353 | flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 | 385 | flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 |
| 354 | flutter_native_contact_picker: 22508d3ab5a583068d4a588cb37c0e0b8764722d | 386 | flutter_native_contact_picker: 22508d3ab5a583068d4a588cb37c0e0b8764722d |
| 355 | flutter_native_image: c0f200e4bc5da392b15878df38654cc85a78b090 | 387 | flutter_native_image: c0f200e4bc5da392b15878df38654cc85a78b090 |
| 388 | + flutter_open_chinese_convert: 8b440f57a1666d3deb57b3c9da6318685a061812 | ||
| 356 | flutter_sms: bb63bc226f41fad2b7cc9f0b1d0c15f7730a8310 | 389 | flutter_sms: bb63bc226f41fad2b7cc9f0b1d0c15f7730a8310 |
| 357 | flutter_statusbarcolor_ns: 28ea17050f37c962fe05a4722a6d43afc4fca76b | 390 | flutter_statusbarcolor_ns: 28ea17050f37c962fe05a4722a6d43afc4fca76b |
| 358 | fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 | 391 | fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 |
| @@ -366,17 +399,20 @@ SPEC CHECKSUMS: | @@ -366,17 +399,20 @@ SPEC CHECKSUMS: | ||
| 366 | Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d | 399 | Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d |
| 367 | MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb | 400 | MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb |
| 368 | open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 | 401 | open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 |
| 402 | + OpenCC: 290dcf27e18ef926aa4fbc0f0a9c116c36145bc5 | ||
| 369 | OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 | 403 | OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 |
| 370 | package_info: cce50adca9873c79f931618469d2114b91d71189 | 404 | package_info: cce50adca9873c79f931618469d2114b91d71189 |
| 371 | path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 | 405 | path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 |
| 372 | permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d | 406 | permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d |
| 373 | photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 | 407 | photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 |
| 374 | ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda | 408 | ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda |
| 409 | + record_darwin: fb1f375f1d9603714f55b8708a903bbb91ffdb0a | ||
| 375 | SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 | 410 | SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 |
| 376 | SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 | 411 | SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 |
| 377 | sendsms: 43a465a88ce570d59cc10ef0f3bdb922cc303c7b | 412 | sendsms: 43a465a88ce570d59cc10ef0f3bdb922cc303c7b |
| 378 | shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 | 413 | shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 |
| 379 | sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 | 414 | sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 |
| 415 | + speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19 | ||
| 380 | sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 | 416 | sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 |
| 381 | SwiftProtobuf: 4dbaffec76a39a8dc5da23b40af1a5dc01a4c02d | 417 | SwiftProtobuf: 4dbaffec76a39a8dc5da23b40af1a5dc01a4c02d |
| 382 | TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 | 418 | TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 |
| @@ -388,6 +424,7 @@ SPEC CHECKSUMS: | @@ -388,6 +424,7 @@ SPEC CHECKSUMS: | ||
| 388 | video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 | 424 | video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 |
| 389 | webview_flutter: fa13f657fd0a01527ab6ba905b3be0fb3e3bfd4d | 425 | webview_flutter: fa13f657fd0a01527ab6ba905b3be0fb3e3bfd4d |
| 390 | WechatOpenSDK-XCFramework: 36fb2bea0754266c17184adf4963d7e6ff98b69f | 426 | WechatOpenSDK-XCFramework: 36fb2bea0754266c17184adf4963d7e6ff98b69f |
| 427 | + whisper_cpp_flutter_plus: b268c1770195390aecafa38e7d3b4711ec007c14 | ||
| 391 | 428 | ||
| 392 | PODFILE CHECKSUM: 9435259418d39cbc3076dcc2f688f77abaddd31d | 429 | PODFILE CHECKSUM: 9435259418d39cbc3076dcc2f688f77abaddd31d |
| 393 | 430 |
| @@ -355,14 +355,10 @@ | @@ -355,14 +355,10 @@ | ||
| 355 | inputFileListPaths = ( | 355 | inputFileListPaths = ( |
| 356 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", | 356 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", |
| 357 | ); | 357 | ); |
| 358 | - inputPaths = ( | ||
| 359 | - ); | ||
| 360 | name = "[CP] Embed Pods Frameworks"; | 358 | name = "[CP] Embed Pods Frameworks"; |
| 361 | outputFileListPaths = ( | 359 | outputFileListPaths = ( |
| 362 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", | 360 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", |
| 363 | ); | 361 | ); |
| 364 | - outputPaths = ( | ||
| 365 | - ); | ||
| 366 | runOnlyForDeploymentPostprocessing = 0; | 362 | runOnlyForDeploymentPostprocessing = 0; |
| 367 | shellPath = /bin/sh; | 363 | shellPath = /bin/sh; |
| 368 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; | 364 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; |
| @@ -376,14 +372,10 @@ | @@ -376,14 +372,10 @@ | ||
| 376 | inputFileListPaths = ( | 372 | inputFileListPaths = ( |
| 377 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", | 373 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", |
| 378 | ); | 374 | ); |
| 379 | - inputPaths = ( | ||
| 380 | - ); | ||
| 381 | name = "[CP] Copy Pods Resources"; | 375 | name = "[CP] Copy Pods Resources"; |
| 382 | outputFileListPaths = ( | 376 | outputFileListPaths = ( |
| 383 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", | 377 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", |
| 384 | ); | 378 | ); |
| 385 | - outputPaths = ( | ||
| 386 | - ); | ||
| 387 | runOnlyForDeploymentPostprocessing = 0; | 379 | runOnlyForDeploymentPostprocessing = 0; |
| 388 | shellPath = /bin/sh; | 380 | shellPath = /bin/sh; |
| 389 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; | 381 | 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'; | @@ -37,6 +37,9 @@ import 'package:one_key/provider/upload_img_provider.dart'; | ||
| 37 | import 'package:one_key/route/navigator_util.dart'; | 37 | import 'package:one_key/route/navigator_util.dart'; |
| 38 | import 'package:one_key/utils/utils.dart'; | 38 | import 'package:one_key/utils/utils.dart'; |
| 39 | import 'package:provider/provider.dart'; | 39 | import 'package:provider/provider.dart'; |
| 40 | +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; | ||
| 41 | +import 'package:one_key/pages/agent/agent_chat_page.dart'; | ||
| 42 | +import 'package:one_key/pages/agent/shared_reservation_receiver.dart'; | ||
| 40 | import 'package:tpns_flutter_plugin/tpns_flutter_plugin.dart'; | 43 | import 'package:tpns_flutter_plugin/tpns_flutter_plugin.dart'; |
| 41 | 44 | ||
| 42 | import 'app.dart'; | 45 | import 'app.dart'; |
| @@ -88,6 +91,7 @@ FutureOr<void> main() async { | @@ -88,6 +91,7 @@ FutureOr<void> main() async { | ||
| 88 | // await FlutterDownloader.initialize(debug: true, ignoreSsl: true); | 91 | // await FlutterDownloader.initialize(debug: true, ignoreSsl: true); |
| 89 | 92 | ||
| 90 | await App.initSp(); | 93 | await App.initSp(); |
| 94 | + _initSharedReservationReceiver(); | ||
| 91 | SystemChrome.setPreferredOrientations( | 95 | SystemChrome.setPreferredOrientations( |
| 92 | [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]) | 96 | [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]) |
| 93 | .then((_) { | 97 | .then((_) { |
| @@ -333,3 +337,70 @@ class MyAppState extends State<MyApp> { | @@ -333,3 +337,70 @@ class MyAppState extends State<MyApp> { | ||
| 333 | }); | 337 | }); |
| 334 | } | 338 | } |
| 335 | } | 339 | } |
| 340 | + | ||
| 341 | +void _initSharedReservationReceiver() { | ||
| 342 | + try { | ||
| 343 | + ReceiveSharingIntent.instance.getMediaStream().listen( | ||
| 344 | + (List<SharedMediaFile> media) => _handleSharedMedia(media), | ||
| 345 | + onError: (Object error) => debugPrint('分享接收失败: $error'), | ||
| 346 | + ); | ||
| 347 | + // 延迟到首帧后再读取初始分享内容,确保 Android Activity 已挂载。 | ||
| 348 | + WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| 349 | + unawaited(_handleInitialSharedMedia()); | ||
| 350 | + }); | ||
| 351 | + } catch (error) { | ||
| 352 | + debugPrint('分享接收初始化失败: $error'); | ||
| 353 | + } | ||
| 354 | +} | ||
| 355 | + | ||
| 356 | +Future<void> _handleInitialSharedMedia() async { | ||
| 357 | + try { | ||
| 358 | + final List<SharedMediaFile> media = | ||
| 359 | + await ReceiveSharingIntent.instance.getInitialMedia(); | ||
| 360 | + if (media.isNotEmpty) { | ||
| 361 | + _handleSharedMedia(media); | ||
| 362 | + } | ||
| 363 | + unawaited(ReceiveSharingIntent.instance.reset()); | ||
| 364 | + } catch (error) { | ||
| 365 | + debugPrint('读取初始分享内容失败: $error'); | ||
| 366 | + } | ||
| 367 | +} | ||
| 368 | + | ||
| 369 | +void _handleSharedMedia(List<SharedMediaFile> media) { | ||
| 370 | + if (media.isEmpty) return; | ||
| 371 | + final SharedMediaFile? image = media | ||
| 372 | + .where((SharedMediaFile file) => file.type == SharedMediaType.image) | ||
| 373 | + .firstOrNull; | ||
| 374 | + if (image == null) return; | ||
| 375 | + | ||
| 376 | + SharedReservationReceiver.instance.acceptImage(image.path); | ||
| 377 | + _openAgentChatForSharedImage(); | ||
| 378 | +} | ||
| 379 | + | ||
| 380 | +bool _agentChatPushPending = false; | ||
| 381 | +int _agentChatPushRetry = 0; | ||
| 382 | + | ||
| 383 | +void _openAgentChatForSharedImage() { | ||
| 384 | + final NavigatorState? navigator = App.navigatorKey.currentState; | ||
| 385 | + if (navigator == null) { | ||
| 386 | + // 冷启动时首帧可能尚未构建完成,稍后重试。 | ||
| 387 | + if (_agentChatPushRetry < 40) { | ||
| 388 | + _agentChatPushRetry++; | ||
| 389 | + WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| 390 | + _openAgentChatForSharedImage(); | ||
| 391 | + }); | ||
| 392 | + } | ||
| 393 | + return; | ||
| 394 | + } | ||
| 395 | + _agentChatPushRetry = 0; | ||
| 396 | + if (_agentChatPushPending) return; | ||
| 397 | + | ||
| 398 | + _agentChatPushPending = true; | ||
| 399 | + navigator | ||
| 400 | + .push( | ||
| 401 | + MaterialPageRoute<void>(builder: (_) => const AgentChatPage()), | ||
| 402 | + ) | ||
| 403 | + .whenComplete(() { | ||
| 404 | + _agentChatPushPending = false; | ||
| 405 | + }); | ||
| 406 | +} |
| 1 | import 'dart:async'; | 1 | import 'dart:async'; |
| 2 | +import 'dart:io'; | ||
| 2 | import 'package:flutter/foundation.dart'; | 3 | import 'package:flutter/foundation.dart'; |
| 3 | import 'package:flutter/material.dart'; | 4 | import 'package:flutter/material.dart'; |
| 4 | import 'package:flutter/services.dart'; | 5 | import 'package:flutter/services.dart'; |
| @@ -6,9 +7,10 @@ import 'package:image_picker/image_picker.dart'; | @@ -6,9 +7,10 @@ import 'package:image_picker/image_picker.dart'; | ||
| 6 | import 'package:one_key/app.dart'; | 7 | import 'package:one_key/app.dart'; |
| 7 | import 'package:one_key/models/notebook_detail.dart'; | 8 | import 'package:one_key/models/notebook_detail.dart'; |
| 8 | import 'package:one_key/pages/agent/agent_chat_store.dart'; | 9 | import 'package:one_key/pages/agent/agent_chat_store.dart'; |
| 9 | -import 'package:one_key/pages/agent/baidu_ocr_client.dart'; | 10 | +import 'package:one_key/pages/agent/qwen_ocr_client.dart'; |
| 10 | import 'package:one_key/pages/agent/reservation_image_parser.dart'; | 11 | import 'package:one_key/pages/agent/reservation_image_parser.dart'; |
| 11 | -import 'package:one_key/pages/agent/tencent_asr_client.dart'; | 12 | +import 'package:one_key/pages/agent/shared_reservation_receiver.dart'; |
| 13 | +import 'package:one_key/pages/agent/paraformer_asr_client.dart'; | ||
| 12 | import 'package:one_key/provider/user_model.dart'; | 14 | import 'package:one_key/provider/user_model.dart'; |
| 13 | import 'package:one_key/res/colors.dart'; | 15 | import 'package:one_key/res/colors.dart'; |
| 14 | import 'package:one_key/route/navigator_util.dart'; | 16 | import 'package:one_key/route/navigator_util.dart'; |
| @@ -75,11 +77,8 @@ class AgentChatPage extends StatefulWidget { | @@ -75,11 +77,8 @@ class AgentChatPage extends StatefulWidget { | ||
| 75 | class _AgentChatPageState extends State<AgentChatPage> { | 77 | class _AgentChatPageState extends State<AgentChatPage> { |
| 76 | static const String _greeting = '你好,我是经营助手。今天想问点什么?比如:“今天营业额多少?”'; | 78 | static const String _greeting = '你好,我是经营助手。今天想问点什么?比如:“今天营业额多少?”'; |
| 77 | // TODO: 正式上线时应由服务端下发密钥,避免客户端泄露。 | 79 | // TODO: 正式上线时应由服务端下发密钥,避免客户端泄露。 |
| 78 | - static const String _tencentSecretId = 'AKID9Vgy8FEC5qSxLcllVCmXzJWaVSI0Z2Nb'; | ||
| 79 | - static const String _tencentSecretKey = 'c2c5MTOBo5d9XilhaLzVSG4DE7qRuCJF'; | ||
| 80 | - static const String _tencentAppId = '1308375824'; | ||
| 81 | - static const String _baiduApiKey = 'ChpdwH1VCou4B7eWF1hBqmn6'; | ||
| 82 | - static const String _baiduSecretKey = 'ayognpdpOYkoHQmBxjQJQzvjnSpqLA6w'; | 80 | + static const String _qwenApiKey = |
| 81 | + 'sk-ws-H.PILIHPE.GQc0.MEUCIFS4lSx9mCvznDxgNHzPthawcyEIlr3W8eTtquLAalb3AiEAivaBkCp122lHoIFBiAeWYaUsIyrVOUbUsqQnPj8ApIs'; | ||
| 83 | static const MethodChannel _speechChannel = MethodChannel('com.naji/book'); | 82 | static const MethodChannel _speechChannel = MethodChannel('com.naji/book'); |
| 84 | 83 | ||
| 85 | final ScrollController _scrollController = ScrollController(); | 84 | final ScrollController _scrollController = ScrollController(); |
| @@ -98,7 +97,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -98,7 +97,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 98 | bool _historyLoading = true; | 97 | bool _historyLoading = true; |
| 99 | bool _usingNativeSpeech = false; | 98 | bool _usingNativeSpeech = false; |
| 100 | bool _usingWhisper = false; | 99 | bool _usingWhisper = false; |
| 101 | - bool _usingTencentAsr = false; | 100 | + bool _usingCloudAsr = false; |
| 102 | bool _whisperModelReady = false; | 101 | bool _whisperModelReady = false; |
| 103 | bool _voicePressActive = false; | 102 | bool _voicePressActive = false; |
| 104 | bool _imageProcessing = false; | 103 | bool _imageProcessing = false; |
| @@ -114,8 +113,9 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -114,8 +113,9 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 114 | WhisperEngine? _whisperEngine; | 113 | WhisperEngine? _whisperEngine; |
| 115 | WhisperStreamTask? _whisperTask; | 114 | WhisperStreamTask? _whisperTask; |
| 116 | StreamSubscription<WhisperStreamUpdate>? _whisperUpdateSub; | 115 | StreamSubscription<WhisperStreamUpdate>? _whisperUpdateSub; |
| 116 | + VoidCallback? _sharedImageListener; | ||
| 117 | Future<bool>? _whisperPrepareFuture; | 117 | Future<bool>? _whisperPrepareFuture; |
| 118 | - TencentAsrSession? _tencentSession; | 118 | + ParaformerAsrSession? _cloudAsrSession; |
| 119 | 119 | ||
| 120 | bool get _supportsWhisper => | 120 | bool get _supportsWhisper => |
| 121 | defaultTargetPlatform == TargetPlatform.android || | 121 | defaultTargetPlatform == TargetPlatform.android || |
| @@ -142,6 +142,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -142,6 +142,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 142 | unawaited(_prewarmWhisperEngine()); | 142 | unawaited(_prewarmWhisperEngine()); |
| 143 | } | 143 | } |
| 144 | _loadHistory(); | 144 | _loadHistory(); |
| 145 | + _initSharedReservationListener(); | ||
| 145 | } | 146 | } |
| 146 | 147 | ||
| 147 | @override | 148 | @override |
| @@ -150,8 +151,14 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -150,8 +151,14 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 150 | _voiceTimer?.cancel(); | 151 | _voiceTimer?.cancel(); |
| 151 | _voiceRequestId++; | 152 | _voiceRequestId++; |
| 152 | _whisperUpdateSub?.cancel(); | 153 | _whisperUpdateSub?.cancel(); |
| 154 | + final VoidCallback? sharedListener = _sharedImageListener; | ||
| 155 | + _sharedImageListener = null; | ||
| 156 | + if (sharedListener != null) { | ||
| 157 | + SharedReservationReceiver.instance.pendingImage | ||
| 158 | + .removeListener(sharedListener); | ||
| 159 | + } | ||
| 153 | unawaited(_whisperTask?.cancel()); | 160 | unawaited(_whisperTask?.cancel()); |
| 154 | - unawaited(_tencentSession?.cancel()); | 161 | + unawaited(_cloudAsrSession?.cancel()); |
| 155 | try { | 162 | try { |
| 156 | _whisperEngine?.dispose(); | 163 | _whisperEngine?.dispose(); |
| 157 | } catch (_) {} | 164 | } catch (_) {} |
| @@ -188,8 +195,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -188,8 +195,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 188 | try { | 195 | try { |
| 189 | final WhisperModelManager manager = | 196 | final WhisperModelManager manager = |
| 190 | _whisperModels ??= WhisperModelManager(); | 197 | _whisperModels ??= WhisperModelManager(); |
| 191 | - final model = | ||
| 192 | - await manager.findCatalogModel(WhisperModelCatalog.base); | 198 | + final model = await manager.findCatalogModel(WhisperModelCatalog.base); |
| 193 | if (model == null || _whisperEngine != null) return; | 199 | if (model == null || _whisperEngine != null) return; |
| 194 | final WhisperEngine engine = await WhisperEngine.load(model.path); | 200 | final WhisperEngine engine = await WhisperEngine.load(model.path); |
| 195 | if (!mounted) { | 201 | if (!mounted) { |
| @@ -370,7 +376,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -370,7 +376,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 370 | final String trimmed = text.trim(); | 376 | final String trimmed = text.trim(); |
| 371 | if (trimmed.isEmpty) return false; | 377 | if (trimmed.isEmpty) return false; |
| 372 | 378 | ||
| 373 | - final ReservationImageParseResult result = ReservationImageParser.parse(trimmed); | 379 | + final ReservationImageParseResult result = |
| 380 | + ReservationImageParser.parse(trimmed); | ||
| 374 | if (!_isReservationText(result)) return false; | 381 | if (!_isReservationText(result)) return false; |
| 375 | 382 | ||
| 376 | unawaited(_showReservationConfirm(_normalizeReservationDate(result))); | 383 | unawaited(_showReservationConfirm(_normalizeReservationDate(result))); |
| @@ -623,8 +630,14 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -623,8 +630,14 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 623 | kind: _CardKind.orderList, | 630 | kind: _CardKind.orderList, |
| 624 | title: '桌${table.group(1)} · 近期订单', | 631 | title: '桌${table.group(1)} · 近期订单', |
| 625 | items: [ | 632 | items: [ |
| 626 | - {'name': '#2026091718 · 桌${table.group(1)}', 'status': '已支付 ¥386'}, | ||
| 627 | - {'name': '#2026091721 · 桌${table.group(1)}', 'status': '待支付 ¥168'}, | 633 | + { |
| 634 | + 'name': '#2026091718 · 桌${table.group(1)}', | ||
| 635 | + 'status': '已支付 ¥386' | ||
| 636 | + }, | ||
| 637 | + { | ||
| 638 | + 'name': '#2026091721 · 桌${table.group(1)}', | ||
| 639 | + 'status': '待支付 ¥168' | ||
| 640 | + }, | ||
| 628 | {'name': '#2026091723 · 桌${table.group(1)}', 'status': '已支付 ¥96'}, | 641 | {'name': '#2026091723 · 桌${table.group(1)}', 'status': '已支付 ¥96'}, |
| 629 | ], | 642 | ], |
| 630 | ), | 643 | ), |
| @@ -840,7 +853,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -840,7 +853,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 840 | _voiceTimer?.cancel(); | 853 | _voiceTimer?.cancel(); |
| 841 | 854 | ||
| 842 | if (_supportsWhisper) { | 855 | if (_supportsWhisper) { |
| 843 | - await _startTencentVoice(requestId); | 856 | + await _startCloudVoice(requestId); |
| 844 | return; | 857 | return; |
| 845 | } | 858 | } |
| 846 | 859 | ||
| @@ -848,9 +861,9 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -848,9 +861,9 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 848 | await _startLegacyVoice(); | 861 | await _startLegacyVoice(); |
| 849 | } | 862 | } |
| 850 | 863 | ||
| 851 | - /// 移动端优先使用腾讯云实时语音识别,失败时回退到离线 Whisper。 | ||
| 852 | - Future<void> _startTencentVoice(int requestId) async { | ||
| 853 | - _usingTencentAsr = true; | 864 | + /// 移动端优先使用百炼 Paraformer 实时语音识别,失败时回退到离线 Whisper。 |
| 865 | + Future<void> _startCloudVoice(int requestId) async { | ||
| 866 | + _usingCloudAsr = true; | ||
| 854 | _usingWhisper = false; | 867 | _usingWhisper = false; |
| 855 | setState(() { | 868 | setState(() { |
| 856 | _voiceStage = _VoiceStage.preparing; | 869 | _voiceStage = _VoiceStage.preparing; |
| @@ -859,24 +872,22 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -859,24 +872,22 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 859 | _voiceHint = '正在连接云端语音识别…'; | 872 | _voiceHint = '正在连接云端语音识别…'; |
| 860 | }); | 873 | }); |
| 861 | 874 | ||
| 862 | - final TencentAsrSession session = TencentAsrSession( | ||
| 863 | - secretId: _tencentSecretId, | ||
| 864 | - secretKey: _tencentSecretKey, | ||
| 865 | - appId: _tencentAppId, | 875 | + final ParaformerAsrSession session = ParaformerAsrSession( |
| 876 | + apiKey: _qwenApiKey, | ||
| 866 | onPartial: (String text) { | 877 | onPartial: (String text) { |
| 867 | if (!mounted || requestId != _voiceRequestId) return; | 878 | if (!mounted || requestId != _voiceRequestId) return; |
| 868 | setState(() => _recognizedText = text); | 879 | setState(() => _recognizedText = text); |
| 869 | }, | 880 | }, |
| 870 | ); | 881 | ); |
| 871 | - _tencentSession = session; | 882 | + _cloudAsrSession = session; |
| 872 | 883 | ||
| 873 | try { | 884 | try { |
| 874 | await session.prepare(); | 885 | await session.prepare(); |
| 875 | } catch (error) { | 886 | } catch (error) { |
| 876 | - debugPrint('Tencent ASR prepare failed: $error'); | 887 | + debugPrint('Paraformer ASR prepare failed: $error'); |
| 877 | if (!mounted || requestId != _voiceRequestId) return; | 888 | if (!mounted || requestId != _voiceRequestId) return; |
| 878 | - _tencentSession = null; | ||
| 879 | - _usingTencentAsr = false; | 889 | + _cloudAsrSession = null; |
| 890 | + _usingCloudAsr = false; | ||
| 880 | await session.dispose(); | 891 | await session.dispose(); |
| 881 | await _startWhisperVoice(requestId); | 892 | await _startWhisperVoice(requestId); |
| 882 | return; | 893 | return; |
| @@ -888,8 +899,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -888,8 +899,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 888 | } | 899 | } |
| 889 | 900 | ||
| 890 | if (!_voicePressActive) { | 901 | if (!_voicePressActive) { |
| 891 | - _tencentSession = null; | ||
| 892 | - _usingTencentAsr = false; | 902 | + _cloudAsrSession = null; |
| 903 | + _usingCloudAsr = false; | ||
| 893 | await session.cancel(); | 904 | await session.cancel(); |
| 894 | if (mounted && requestId == _voiceRequestId) { | 905 | if (mounted && requestId == _voiceRequestId) { |
| 895 | setState(() { | 906 | setState(() { |
| @@ -904,10 +915,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -904,10 +915,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 904 | try { | 915 | try { |
| 905 | await session.startAudio(); | 916 | await session.startAudio(); |
| 906 | } catch (error) { | 917 | } catch (error) { |
| 907 | - debugPrint('Tencent ASR start failed: $error'); | 918 | + debugPrint('Paraformer ASR start failed: $error'); |
| 908 | if (!mounted || requestId != _voiceRequestId) return; | 919 | if (!mounted || requestId != _voiceRequestId) return; |
| 909 | - _tencentSession = null; | ||
| 910 | - _usingTencentAsr = false; | 920 | + _cloudAsrSession = null; |
| 921 | + _usingCloudAsr = false; | ||
| 911 | await session.dispose(); | 922 | await session.dispose(); |
| 912 | await _startWhisperVoice(requestId); | 923 | await _startWhisperVoice(requestId); |
| 913 | return; | 924 | return; |
| @@ -972,20 +983,20 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -972,20 +983,20 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 972 | await _startWhisperRecording(requestId); | 983 | await _startWhisperRecording(requestId); |
| 973 | } | 984 | } |
| 974 | 985 | ||
| 975 | - Future<void> _stopTencentVoice(int requestId) async { | 986 | + Future<void> _stopCloudVoice(int requestId) async { |
| 976 | _voiceTimer?.cancel(); | 987 | _voiceTimer?.cancel(); |
| 977 | - final TencentAsrSession? session = _tencentSession; | 988 | + final ParaformerAsrSession? session = _cloudAsrSession; |
| 978 | if (session == null) return; | 989 | if (session == null) return; |
| 979 | 990 | ||
| 980 | String? text; | 991 | String? text; |
| 981 | try { | 992 | try { |
| 982 | text = await session.stop(); | 993 | text = await session.stop(); |
| 983 | } catch (error) { | 994 | } catch (error) { |
| 984 | - debugPrint('Tencent ASR stop failed: $error'); | 995 | + debugPrint('Paraformer ASR stop failed: $error'); |
| 985 | } | 996 | } |
| 986 | unawaited(session.dispose()); | 997 | unawaited(session.dispose()); |
| 987 | - _tencentSession = null; | ||
| 988 | - _usingTencentAsr = false; | 998 | + _cloudAsrSession = null; |
| 999 | + _usingCloudAsr = false; | ||
| 989 | if (!mounted || requestId != _voiceRequestId) return; | 1000 | if (!mounted || requestId != _voiceRequestId) return; |
| 990 | await _finishVoiceWithText(text); | 1001 | await _finishVoiceWithText(text); |
| 991 | } | 1002 | } |
| @@ -1240,9 +1251,9 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1240,9 +1251,9 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1240 | if (!mounted) return; | 1251 | if (!mounted) return; |
| 1241 | if (_voiceStage != _VoiceStage.recording) return; | 1252 | if (_voiceStage != _VoiceStage.recording) return; |
| 1242 | 1253 | ||
| 1243 | - if (_usingTencentAsr) { | 1254 | + if (_usingCloudAsr) { |
| 1244 | final int requestId = _voiceRequestId; | 1255 | final int requestId = _voiceRequestId; |
| 1245 | - await _stopTencentVoice(requestId); | 1256 | + await _stopCloudVoice(requestId); |
| 1246 | return; | 1257 | return; |
| 1247 | } | 1258 | } |
| 1248 | 1259 | ||
| @@ -1308,8 +1319,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1308,8 +1319,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1308 | _voiceTimer?.cancel(); | 1319 | _voiceTimer?.cancel(); |
| 1309 | _usingNativeSpeech = false; | 1320 | _usingNativeSpeech = false; |
| 1310 | _usingWhisper = false; | 1321 | _usingWhisper = false; |
| 1311 | - _usingTencentAsr = false; | ||
| 1312 | - _tencentSession = null; | 1322 | + _usingCloudAsr = false; |
| 1323 | + _cloudAsrSession = null; | ||
| 1313 | _whisperUpdateSub?.cancel(); | 1324 | _whisperUpdateSub?.cancel(); |
| 1314 | _whisperUpdateSub = null; | 1325 | _whisperUpdateSub = null; |
| 1315 | _whisperTask = null; | 1326 | _whisperTask = null; |
| @@ -1344,9 +1355,9 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1344,9 +1355,9 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1344 | _voiceRequestId++; | 1355 | _voiceRequestId++; |
| 1345 | _usingWhisper = false; | 1356 | _usingWhisper = false; |
| 1346 | _usingNativeSpeech = false; | 1357 | _usingNativeSpeech = false; |
| 1347 | - _usingTencentAsr = false; | ||
| 1348 | - unawaited(_tencentSession?.cancel()); | ||
| 1349 | - _tencentSession = null; | 1358 | + _usingCloudAsr = false; |
| 1359 | + unawaited(_cloudAsrSession?.cancel()); | ||
| 1360 | + _cloudAsrSession = null; | ||
| 1350 | _whisperUpdateSub?.cancel(); | 1361 | _whisperUpdateSub?.cancel(); |
| 1351 | _whisperUpdateSub = null; | 1362 | _whisperUpdateSub = null; |
| 1352 | _whisperTask = null; | 1363 | _whisperTask = null; |
| @@ -1367,10 +1378,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1367,10 +1378,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1367 | _voiceTimer?.cancel(); | 1378 | _voiceTimer?.cancel(); |
| 1368 | _voiceRequestId++; | 1379 | _voiceRequestId++; |
| 1369 | _usingWhisper = false; | 1380 | _usingWhisper = false; |
| 1370 | - if (_usingTencentAsr) { | ||
| 1371 | - _usingTencentAsr = false; | ||
| 1372 | - unawaited(_tencentSession?.cancel()); | ||
| 1373 | - _tencentSession = null; | 1381 | + if (_usingCloudAsr) { |
| 1382 | + _usingCloudAsr = false; | ||
| 1383 | + unawaited(_cloudAsrSession?.cancel()); | ||
| 1384 | + _cloudAsrSession = null; | ||
| 1374 | } | 1385 | } |
| 1375 | if (!mounted) return; | 1386 | if (!mounted) return; |
| 1376 | setState(() { | 1387 | setState(() { |
| @@ -1384,10 +1395,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1384,10 +1395,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1384 | _voiceTimer?.cancel(); | 1395 | _voiceTimer?.cancel(); |
| 1385 | _voiceRequestId++; | 1396 | _voiceRequestId++; |
| 1386 | _usingWhisper = false; | 1397 | _usingWhisper = false; |
| 1387 | - if (_usingTencentAsr) { | ||
| 1388 | - _usingTencentAsr = false; | ||
| 1389 | - final TencentAsrSession? session = _tencentSession; | ||
| 1390 | - _tencentSession = null; | 1398 | + if (_usingCloudAsr) { |
| 1399 | + _usingCloudAsr = false; | ||
| 1400 | + final ParaformerAsrSession? session = _cloudAsrSession; | ||
| 1401 | + _cloudAsrSession = null; | ||
| 1391 | if (session != null) { | 1402 | if (session != null) { |
| 1392 | try { | 1403 | try { |
| 1393 | await session.cancel(); | 1404 | await session.cancel(); |
| @@ -1429,27 +1440,62 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1429,27 +1440,62 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1429 | 1440 | ||
| 1430 | Future<void> _pickReservationImage() async { | 1441 | Future<void> _pickReservationImage() async { |
| 1431 | if (_imageProcessing) return; | 1442 | if (_imageProcessing) return; |
| 1432 | - setState(() => _imageProcessing = true); | 1443 | + final XFile? file = await ImagePicker().pickImage( |
| 1444 | + source: ImageSource.gallery, | ||
| 1445 | + maxWidth: 1600, | ||
| 1446 | + imageQuality: 80, | ||
| 1447 | + ); | ||
| 1448 | + if (file == null) return; | ||
| 1449 | + await _processReservationImageBytes(await file.readAsBytes()); | ||
| 1450 | + } | ||
| 1451 | + | ||
| 1452 | + /// 订阅外部分享(如微信截图)进来的图片,自动进入预订识别流程。 | ||
| 1453 | + void _initSharedReservationListener() { | ||
| 1454 | + final SharedReservationReceiver receiver = | ||
| 1455 | + SharedReservationReceiver.instance; | ||
| 1456 | + _sharedImageListener = () { | ||
| 1457 | + final String? path = receiver.pendingImage.value; | ||
| 1458 | + if (path == null) return; | ||
| 1459 | + unawaited(_processSharedReservationImage(path)); | ||
| 1460 | + }; | ||
| 1461 | + receiver.pendingImage.addListener(_sharedImageListener!); | ||
| 1462 | + | ||
| 1463 | + final String? initialPath = receiver.pendingImage.value; | ||
| 1464 | + if (initialPath != null) { | ||
| 1465 | + WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| 1466 | + unawaited(_processSharedReservationImage(initialPath)); | ||
| 1467 | + }); | ||
| 1468 | + } | ||
| 1469 | + } | ||
| 1433 | 1470 | ||
| 1471 | + Future<void> _processSharedReservationImage(String path) async { | ||
| 1472 | + if (_imageProcessing) return; | ||
| 1434 | try { | 1473 | try { |
| 1435 | - final XFile? file = await ImagePicker().pickImage( | ||
| 1436 | - source: ImageSource.gallery, | ||
| 1437 | - maxWidth: 1600, | ||
| 1438 | - imageQuality: 80, | ||
| 1439 | - ); | ||
| 1440 | - if (file == null) return; | ||
| 1441 | - | ||
| 1442 | - final Uint8List bytes = await file.readAsBytes(); | ||
| 1443 | - final BaiduOcrClient ocr = BaiduOcrClient( | ||
| 1444 | - apiKey: _baiduApiKey, | ||
| 1445 | - secretKey: _baiduSecretKey, | ||
| 1446 | - ); | ||
| 1447 | - final String text = await ocr.recognize(bytes); | ||
| 1448 | - debugPrint('OCR识别成功: ${text.length}字'); | 1474 | + final Uint8List bytes = await File(path).readAsBytes(); |
| 1449 | if (!mounted) return; | 1475 | if (!mounted) return; |
| 1476 | + await _processReservationImageBytes(bytes); | ||
| 1477 | + } catch (error) { | ||
| 1478 | + debugPrint('读取分享图片失败: $error'); | ||
| 1479 | + if (mounted) { | ||
| 1480 | + ScaffoldMessenger.of(context).showSnackBar( | ||
| 1481 | + const SnackBar(content: Text('读取分享图片失败,请重试')), | ||
| 1482 | + ); | ||
| 1483 | + } | ||
| 1484 | + } finally { | ||
| 1485 | + SharedReservationReceiver.instance.consumeImage(); | ||
| 1486 | + } | ||
| 1487 | + } | ||
| 1450 | 1488 | ||
| 1489 | + Future<void> _processReservationImageBytes(Uint8List bytes) async { | ||
| 1490 | + if (_imageProcessing) return; | ||
| 1491 | + setState(() => _imageProcessing = true); | ||
| 1492 | + try { | ||
| 1493 | + final QwenOcrClient ocr = QwenOcrClient(apiKey: _qwenApiKey); | ||
| 1451 | final ReservationImageParseResult result = | 1494 | final ReservationImageParseResult result = |
| 1452 | - ReservationImageParser.parse(text); | 1495 | + await ocr.parseReservation(bytes); |
| 1496 | + debugPrint('千问OCR识别成功: ${result.rawText.length}字'); | ||
| 1497 | + if (!mounted) return; | ||
| 1498 | + | ||
| 1453 | if (!result.hasAny) { | 1499 | if (!result.hasAny) { |
| 1454 | ScaffoldMessenger.of(context).showSnackBar( | 1500 | ScaffoldMessenger.of(context).showSnackBar( |
| 1455 | const SnackBar(content: Text('没有从图片中识别到预订信息')), | 1501 | const SnackBar(content: Text('没有从图片中识别到预订信息')), |
| @@ -1459,7 +1505,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1459,7 +1505,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1459 | 1505 | ||
| 1460 | setState(() => _imageProcessing = false); | 1506 | setState(() => _imageProcessing = false); |
| 1461 | await _showReservationConfirm(_normalizeReservationDate(result)); | 1507 | await _showReservationConfirm(_normalizeReservationDate(result)); |
| 1462 | - } on BaiduOcrException catch (error) { | 1508 | + } on QwenOcrException catch (error) { |
| 1463 | debugPrint('OCR失败: ${error.toString()}'); | 1509 | debugPrint('OCR失败: ${error.toString()}'); |
| 1464 | if (mounted) { | 1510 | if (mounted) { |
| 1465 | ScaffoldMessenger.of(context).showSnackBar( | 1511 | ScaffoldMessenger.of(context).showSnackBar( |
| @@ -1511,10 +1557,11 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1511,10 +1557,11 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1511 | if (timeText.isNotEmpty) timeText, | 1557 | if (timeText.isNotEmpty) timeText, |
| 1512 | ].join(' '); | 1558 | ].join(' '); |
| 1513 | 1559 | ||
| 1514 | - final DateTime? bookingTime = _parseReservationTime(combined); | 1560 | + DateTime? bookingTime = _parseReservationTime(combined); |
| 1515 | if (bookingTime == null || combined.isEmpty) { | 1561 | if (bookingTime == null || combined.isEmpty) { |
| 1516 | return result; | 1562 | return result; |
| 1517 | } | 1563 | } |
| 1564 | + bookingTime = _adjustFutureDate(bookingTime, result.rawText); | ||
| 1518 | 1565 | ||
| 1519 | final String month = bookingTime.month.toString().padLeft(2, '0'); | 1566 | final String month = bookingTime.month.toString().padLeft(2, '0'); |
| 1520 | final String day = bookingTime.day.toString().padLeft(2, '0'); | 1567 | final String day = bookingTime.day.toString().padLeft(2, '0'); |
| @@ -1563,6 +1610,67 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1563,6 +1610,67 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1563 | NavigatorUtil.goNoteBookChangeFPage(context, detail, ''); | 1610 | NavigatorUtil.goNoteBookChangeFPage(context, detail, ''); |
| 1564 | } | 1611 | } |
| 1565 | 1612 | ||
| 1613 | + DateTime _adjustFutureDate(DateTime date, String rawText) { | ||
| 1614 | + final DateTime now = DateTime.now(); | ||
| 1615 | + final DateTime today = DateTime(now.year, now.month, now.day); | ||
| 1616 | + if (!DateTime(date.year, date.month, date.day).isBefore(today)) { | ||
| 1617 | + return date; | ||
| 1618 | + } | ||
| 1619 | + | ||
| 1620 | + final int? weekday = _weekdayFromText(rawText); | ||
| 1621 | + final bool hasMonth = | ||
| 1622 | + RegExp(r'\d{1,2}\s*月|\d{4}[-/.]\d{1,2}').hasMatch(rawText); | ||
| 1623 | + | ||
| 1624 | + DateTime? result; | ||
| 1625 | + if (weekday != null) { | ||
| 1626 | + for (int i = 1; i <= 3660; i++) { | ||
| 1627 | + final DateTime cursor = today.add(Duration(days: i)); | ||
| 1628 | + if (cursor.day == date.day && cursor.weekday == weekday) { | ||
| 1629 | + result = cursor; | ||
| 1630 | + break; | ||
| 1631 | + } | ||
| 1632 | + } | ||
| 1633 | + } else if (hasMonth) { | ||
| 1634 | + DateTime candidate = DateTime(now.year, date.month, date.day); | ||
| 1635 | + if (candidate.isBefore(today)) { | ||
| 1636 | + candidate = DateTime(now.year + 1, date.month, date.day); | ||
| 1637 | + } | ||
| 1638 | + result = candidate; | ||
| 1639 | + } else { | ||
| 1640 | + DateTime candidate = DateTime(now.year, now.month, date.day); | ||
| 1641 | + if (candidate.isBefore(today)) { | ||
| 1642 | + candidate = DateTime(now.year, now.month + 1, date.day); | ||
| 1643 | + } | ||
| 1644 | + result = candidate; | ||
| 1645 | + } | ||
| 1646 | + | ||
| 1647 | + final DateTime safe = result ?? date; | ||
| 1648 | + return DateTime(safe.year, safe.month, safe.day, date.hour, date.minute); | ||
| 1649 | + } | ||
| 1650 | + | ||
| 1651 | + int? _weekdayFromText(String text) { | ||
| 1652 | + if (RegExp(r'周一').hasMatch(text)) return 1; | ||
| 1653 | + const Map<String, int> mapping = <String, int>{ | ||
| 1654 | + '周二': 2, | ||
| 1655 | + '星期二': 2, | ||
| 1656 | + '周三': 3, | ||
| 1657 | + '星期三': 3, | ||
| 1658 | + '周四': 4, | ||
| 1659 | + '星期四': 4, | ||
| 1660 | + '周五': 5, | ||
| 1661 | + '星期五': 5, | ||
| 1662 | + '周六': 6, | ||
| 1663 | + '星期六': 6, | ||
| 1664 | + '周日': 7, | ||
| 1665 | + '星期日': 7, | ||
| 1666 | + '星期天': 7, | ||
| 1667 | + }; | ||
| 1668 | + for (final MapEntry<String, int> entry in mapping.entries) { | ||
| 1669 | + if (text.contains(entry.key)) return entry.value; | ||
| 1670 | + } | ||
| 1671 | + return null; | ||
| 1672 | + } | ||
| 1673 | + | ||
| 1566 | String _sexCode(String sex) { | 1674 | String _sexCode(String sex) { |
| 1567 | switch (sex) { | 1675 | switch (sex) { |
| 1568 | case '先生': | 1676 | case '先生': |
| @@ -1593,8 +1701,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1593,8 +1701,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1593 | int.parse(fullDate.group(3)!), | 1701 | int.parse(fullDate.group(3)!), |
| 1594 | ); | 1702 | ); |
| 1595 | } else { | 1703 | } else { |
| 1596 | - final Match? monthDay = | ||
| 1597 | - RegExp(r'(\d{1,2})月(\d{1,2})日').firstMatch(text); | 1704 | + final Match? monthDay = RegExp(r'(\d{1,2})月(\d{1,2})日').firstMatch(text); |
| 1598 | if (monthDay != null) { | 1705 | if (monthDay != null) { |
| 1599 | date = DateTime( | 1706 | date = DateTime( |
| 1600 | now.year, | 1707 | now.year, |
| @@ -1674,103 +1781,103 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1674,103 +1781,103 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1674 | statusBarBrightness: Brightness.light, | 1781 | statusBarBrightness: Brightness.light, |
| 1675 | ), | 1782 | ), |
| 1676 | child: Scaffold( | 1783 | child: Scaffold( |
| 1677 | - backgroundColor: const Color(0xFFF3F8F8), | ||
| 1678 | - appBar: AppBar( | ||
| 1679 | - backgroundColor: themeColor, | ||
| 1680 | - foregroundColor: Colors.white, | ||
| 1681 | - elevation: 0, | ||
| 1682 | - centerTitle: true, | ||
| 1683 | - title: Column( | ||
| 1684 | - mainAxisSize: MainAxisSize.min, | ||
| 1685 | - children: [ | ||
| 1686 | - const Text( | ||
| 1687 | - 'AI 经营助手', | ||
| 1688 | - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), | ||
| 1689 | - ), | ||
| 1690 | - Text( | ||
| 1691 | - _storeName, | ||
| 1692 | - style: const TextStyle( | ||
| 1693 | - fontSize: 11, | ||
| 1694 | - color: Colors.white70, | 1784 | + backgroundColor: const Color(0xFFF3F8F8), |
| 1785 | + appBar: AppBar( | ||
| 1786 | + backgroundColor: themeColor, | ||
| 1787 | + foregroundColor: Colors.white, | ||
| 1788 | + elevation: 0, | ||
| 1789 | + centerTitle: true, | ||
| 1790 | + title: Column( | ||
| 1791 | + mainAxisSize: MainAxisSize.min, | ||
| 1792 | + children: [ | ||
| 1793 | + const Text( | ||
| 1794 | + 'AI 经营助手', | ||
| 1795 | + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), | ||
| 1695 | ), | 1796 | ), |
| 1696 | - ), | ||
| 1697 | - ], | 1797 | + Text( |
| 1798 | + _storeName, | ||
| 1799 | + style: const TextStyle( | ||
| 1800 | + fontSize: 11, | ||
| 1801 | + color: Colors.white70, | ||
| 1802 | + ), | ||
| 1803 | + ), | ||
| 1804 | + ], | ||
| 1805 | + ), | ||
| 1698 | ), | 1806 | ), |
| 1699 | - ), | ||
| 1700 | - body: Stack( | ||
| 1701 | - children: [ | ||
| 1702 | - Column( | ||
| 1703 | - children: [ | ||
| 1704 | - Expanded( | ||
| 1705 | - child: _historyLoading | ||
| 1706 | - ? const Center( | ||
| 1707 | - child: CircularProgressIndicator( | ||
| 1708 | - color: MyColours.color_199095, | ||
| 1709 | - ), | ||
| 1710 | - ) | ||
| 1711 | - : ListView.builder( | ||
| 1712 | - controller: _scrollController, | ||
| 1713 | - padding: const EdgeInsets.symmetric( | ||
| 1714 | - horizontal: 12, | ||
| 1715 | - vertical: 14, | 1807 | + body: Stack( |
| 1808 | + children: [ | ||
| 1809 | + Column( | ||
| 1810 | + children: [ | ||
| 1811 | + Expanded( | ||
| 1812 | + child: _historyLoading | ||
| 1813 | + ? const Center( | ||
| 1814 | + child: CircularProgressIndicator( | ||
| 1815 | + color: MyColours.color_199095, | ||
| 1816 | + ), | ||
| 1817 | + ) | ||
| 1818 | + : ListView.builder( | ||
| 1819 | + controller: _scrollController, | ||
| 1820 | + padding: const EdgeInsets.symmetric( | ||
| 1821 | + horizontal: 12, | ||
| 1822 | + vertical: 14, | ||
| 1823 | + ), | ||
| 1824 | + itemCount: _messages.length, | ||
| 1825 | + itemBuilder: (_, int index) { | ||
| 1826 | + return _MessageBubble( | ||
| 1827 | + message: _messages[index], | ||
| 1828 | + themeColor: themeColor, | ||
| 1829 | + ); | ||
| 1830 | + }, | ||
| 1716 | ), | 1831 | ), |
| 1717 | - itemCount: _messages.length, | ||
| 1718 | - itemBuilder: (_, int index) { | ||
| 1719 | - return _MessageBubble( | ||
| 1720 | - message: _messages[index], | ||
| 1721 | - themeColor: themeColor, | ||
| 1722 | - ); | ||
| 1723 | - }, | ||
| 1724 | - ), | 1832 | + ), |
| 1833 | + _QuickPrompts( | ||
| 1834 | + controller: _promptScrollController, | ||
| 1835 | + themeColor: themeColor, | ||
| 1836 | + onSend: _send, | ||
| 1837 | + ), | ||
| 1838 | + _InputBar( | ||
| 1839 | + controller: _inputController, | ||
| 1840 | + streaming: _streaming, | ||
| 1841 | + themeColor: themeColor, | ||
| 1842 | + onSend: _send, | ||
| 1843 | + onStop: _stop, | ||
| 1844 | + onVoiceLongPressStart: _startVoice, | ||
| 1845 | + onVoiceLongPressEnd: _stopVoice, | ||
| 1846 | + onVoiceTap: _showVoiceTapHint, | ||
| 1847 | + onPickImage: _pickReservationImage, | ||
| 1848 | + imageProcessing: _imageProcessing, | ||
| 1849 | + ), | ||
| 1850 | + ], | ||
| 1851 | + ), | ||
| 1852 | + if (_voiceStage == _VoiceStage.preparing) | ||
| 1853 | + _PreparingVoiceOverlay( | ||
| 1854 | + hint: _voiceHint, | ||
| 1855 | + progress: _whisperDownloadProgress, | ||
| 1856 | + themeColor: themeColor, | ||
| 1857 | + onCancel: _cancelVoicePreparation, | ||
| 1725 | ), | 1858 | ), |
| 1726 | - _QuickPrompts( | ||
| 1727 | - controller: _promptScrollController, | 1859 | + if (_voiceStage == _VoiceStage.recording) |
| 1860 | + _RecordOverlay( | ||
| 1861 | + seconds: _voiceSeconds, | ||
| 1728 | themeColor: themeColor, | 1862 | themeColor: themeColor, |
| 1729 | - onSend: _send, | 1863 | + recognizedText: _recognizedText, |
| 1864 | + onStop: _stopVoice, | ||
| 1865 | + onCancel: _cancelVoiceRecording, | ||
| 1730 | ), | 1866 | ), |
| 1731 | - _InputBar( | ||
| 1732 | - controller: _inputController, | ||
| 1733 | - streaming: _streaming, | 1867 | + if (_voiceStage == _VoiceStage.confirming) |
| 1868 | + _ConfirmVoiceBox( | ||
| 1869 | + initialText: _confirmText, | ||
| 1734 | themeColor: themeColor, | 1870 | themeColor: themeColor, |
| 1735 | - onSend: _send, | ||
| 1736 | - onStop: _stop, | ||
| 1737 | - onVoiceLongPressStart: _startVoice, | ||
| 1738 | - onVoiceLongPressEnd: _stopVoice, | ||
| 1739 | - onVoiceTap: _showVoiceTapHint, | ||
| 1740 | - onPickImage: _pickReservationImage, | ||
| 1741 | - imageProcessing: _imageProcessing, | 1871 | + onCancel: () { |
| 1872 | + setState(() => _voiceStage = _VoiceStage.idle); | ||
| 1873 | + }, | ||
| 1874 | + onConfirm: _confirmVoice, | ||
| 1875 | + onChanged: (String value) => _confirmText = value, | ||
| 1742 | ), | 1876 | ), |
| 1743 | - ], | ||
| 1744 | - ), | ||
| 1745 | - if (_voiceStage == _VoiceStage.preparing) | ||
| 1746 | - _PreparingVoiceOverlay( | ||
| 1747 | - hint: _voiceHint, | ||
| 1748 | - progress: _whisperDownloadProgress, | ||
| 1749 | - themeColor: themeColor, | ||
| 1750 | - onCancel: _cancelVoicePreparation, | ||
| 1751 | - ), | ||
| 1752 | - if (_voiceStage == _VoiceStage.recording) | ||
| 1753 | - _RecordOverlay( | ||
| 1754 | - seconds: _voiceSeconds, | ||
| 1755 | - themeColor: themeColor, | ||
| 1756 | - recognizedText: _recognizedText, | ||
| 1757 | - onStop: _stopVoice, | ||
| 1758 | - onCancel: _cancelVoiceRecording, | ||
| 1759 | - ), | ||
| 1760 | - if (_voiceStage == _VoiceStage.confirming) | ||
| 1761 | - _ConfirmVoiceBox( | ||
| 1762 | - initialText: _confirmText, | ||
| 1763 | - themeColor: themeColor, | ||
| 1764 | - onCancel: () { | ||
| 1765 | - setState(() => _voiceStage = _VoiceStage.idle); | ||
| 1766 | - }, | ||
| 1767 | - onConfirm: _confirmVoice, | ||
| 1768 | - onChanged: (String value) => _confirmText = value, | ||
| 1769 | - ), | ||
| 1770 | - ], | 1877 | + ], |
| 1878 | + ), | ||
| 1771 | ), | 1879 | ), |
| 1772 | - ), | ||
| 1773 | - ); | 1880 | + ); |
| 1774 | } | 1881 | } |
| 1775 | } | 1882 | } |
| 1776 | 1883 | ||
| @@ -2087,7 +2194,8 @@ class _ReservationConfirmSheetState extends State<_ReservationConfirmSheet> { | @@ -2087,7 +2194,8 @@ class _ReservationConfirmSheetState extends State<_ReservationConfirmSheet> { | ||
| 2087 | _field('预订人', _nameController), | 2194 | _field('预订人', _nameController), |
| 2088 | _field('称谓', _sexController), | 2195 | _field('称谓', _sexController), |
| 2089 | _field('联系电话', _phoneController, keyboardType: TextInputType.phone), | 2196 | _field('联系电话', _phoneController, keyboardType: TextInputType.phone), |
| 2090 | - _field('用餐人数', _partyController, keyboardType: TextInputType.number), | 2197 | + _field('用餐人数', _partyController, |
| 2198 | + keyboardType: TextInputType.number), | ||
| 2091 | _field('预订日期', _dateController), | 2199 | _field('预订日期', _dateController), |
| 2092 | _field('用餐时段', _mealController), | 2200 | _field('用餐时段', _mealController), |
| 2093 | _field('预订时间', _timeController), | 2201 | _field('预订时间', _timeController), |
| @@ -2640,7 +2748,8 @@ class _AgentCardDetailPage extends StatelessWidget { | @@ -2640,7 +2748,8 @@ class _AgentCardDetailPage extends StatelessWidget { | ||
| 2640 | ]); | 2748 | ]); |
| 2641 | return rows; | 2749 | return rows; |
| 2642 | case _CardKind.tableList: | 2750 | case _CardKind.tableList: |
| 2643 | - final List<_DetailRow> rows = card.items.map((Map<String, dynamic> item) { | 2751 | + final List<_DetailRow> rows = |
| 2752 | + card.items.map((Map<String, dynamic> item) { | ||
| 2644 | final String rawName = '${item['name'] ?? ''}'; | 2753 | final String rawName = '${item['name'] ?? ''}'; |
| 2645 | final String tableName = rawName.split(' · ').first.trim(); | 2754 | final String tableName = rawName.split(' · ').first.trim(); |
| 2646 | final String status = '${item['status'] ?? ''}'; | 2755 | final String status = '${item['status'] ?? ''}'; |
| 1 | import 'dart:async'; | 1 | import 'dart:async'; |
| 2 | import 'dart:convert'; | 2 | import 'dart:convert'; |
| 3 | -import 'package:crypto/crypto.dart'; | 3 | +import 'dart:math'; |
| 4 | + | ||
| 4 | import 'package:flutter/foundation.dart'; | 5 | import 'package:flutter/foundation.dart'; |
| 5 | import 'package:permission_handler/permission_handler.dart'; | 6 | import 'package:permission_handler/permission_handler.dart'; |
| 6 | import 'package:record/record.dart'; | 7 | import 'package:record/record.dart'; |
| 8 | +import 'package:web_socket_channel/io.dart'; | ||
| 7 | import 'package:web_socket_channel/web_socket_channel.dart'; | 9 | import 'package:web_socket_channel/web_socket_channel.dart'; |
| 8 | 10 | ||
| 9 | -/// 腾讯云实时语音识别(WebSocket)客户端。 | 11 | +/// 阿里云百炼 Paraformer 实时语音识别(WebSocket)客户端。 |
| 10 | /// | 12 | /// |
| 11 | -/// 协议说明:https://cloud.tencent.com/document/product/1093/48982 | ||
| 12 | -class TencentAsrClient { | ||
| 13 | - TencentAsrClient({ | ||
| 14 | - required this.secretId, | ||
| 15 | - required this.secretKey, | ||
| 16 | - required this.appId, | ||
| 17 | - this.engineModelType = '16k_zh', | 13 | +/// 协议说明: |
| 14 | +/// https://help.aliyun.com/zh/model-studio/websocket-for-paraformer-real-time-service | ||
| 15 | +class ParaformerAsrClient { | ||
| 16 | + ParaformerAsrClient({ | ||
| 17 | + required this.apiKey, | ||
| 18 | + this.model = 'paraformer-realtime-v2', | ||
| 18 | }); | 19 | }); |
| 19 | 20 | ||
| 20 | - final String secretId; | ||
| 21 | - final String secretKey; | ||
| 22 | - final String appId; | ||
| 23 | - final String engineModelType; | 21 | + /// 统一复用百炼 API Key(与图片识别共用一个 Key)。 |
| 22 | + final String apiKey; | ||
| 23 | + final String model; | ||
| 24 | 24 | ||
| 25 | static const int _frameBytes = 6400; | 25 | static const int _frameBytes = 6400; |
| 26 | 26 | ||
| @@ -35,22 +35,25 @@ class TencentAsrClient { | @@ -35,22 +35,25 @@ class TencentAsrClient { | ||
| 35 | final List<String> _sentences = <String>[]; | 35 | final List<String> _sentences = <String>[]; |
| 36 | String _partial = ''; | 36 | String _partial = ''; |
| 37 | void Function(String text)? _onPartial; | 37 | void Function(String text)? _onPartial; |
| 38 | - bool _handshakeDone = false; | 38 | + String _taskId = ''; |
| 39 | 39 | ||
| 40 | String _combinedText() => (_sentences.join() + _partial).trim(); | 40 | String _combinedText() => (_sentences.join() + _partial).trim(); |
| 41 | 41 | ||
| 42 | - /// 建立连接并等待云端握手成功。失败时抛出 [TencentAsrException]。 | 42 | + /// 建立连接、发送 run-task 并等待服务端 task-started。 |
| 43 | + /// 失败时抛出 [ParaformerAsrException]。 | ||
| 43 | Future<void> connect() async { | 44 | Future<void> connect() async { |
| 44 | - final String voiceId = _newVoiceId(); | ||
| 45 | - final Uri uri = _buildUri(voiceId); | ||
| 46 | - final WebSocketChannel channel = WebSocketChannel.connect(uri); | 45 | + _taskId = _newUuidV4(); |
| 46 | + final WebSocketChannel channel = IOWebSocketChannel.connect( | ||
| 47 | + Uri.parse('wss://dashscope.aliyuncs.com/api-ws/v1/inference'), | ||
| 48 | + headers: <String, dynamic>{'Authorization': 'Bearer $apiKey'}, | ||
| 49 | + ); | ||
| 47 | _channel = channel; | 50 | _channel = channel; |
| 48 | 51 | ||
| 49 | try { | 52 | try { |
| 50 | await channel.ready.timeout(const Duration(seconds: 8)); | 53 | await channel.ready.timeout(const Duration(seconds: 8)); |
| 51 | } catch (error) { | 54 | } catch (error) { |
| 52 | await _closeSocket(); | 55 | await _closeSocket(); |
| 53 | - throw const TencentAsrException(0, '无法连接腾讯云语音服务,请检查网络'); | 56 | + throw const ParaformerAsrException(0, '无法连接百炼语音服务,请检查网络'); |
| 54 | } | 57 | } |
| 55 | 58 | ||
| 56 | _handshake = Completer<void>(); | 59 | _handshake = Completer<void>(); |
| @@ -65,19 +68,20 @@ class TencentAsrClient { | @@ -65,19 +68,20 @@ class TencentAsrClient { | ||
| 65 | }, | 68 | }, |
| 66 | ); | 69 | ); |
| 67 | 70 | ||
| 71 | + _sendText(jsonEncode(_runTaskPayload())); | ||
| 72 | + | ||
| 68 | try { | 73 | try { |
| 69 | await _handshake!.future.timeout(const Duration(seconds: 8)); | 74 | await _handshake!.future.timeout(const Duration(seconds: 8)); |
| 70 | - _handshakeDone = true; | ||
| 71 | - debugPrint('Tencent ASR connected'); | 75 | + debugPrint('Paraformer ASR connected'); |
| 72 | } catch (error) { | 76 | } catch (error) { |
| 73 | await _teardown(); | 77 | await _teardown(); |
| 74 | - if (error is TencentAsrException) rethrow; | ||
| 75 | - throw const TencentAsrException(0, '语音服务握手超时'); | 78 | + if (error is ParaformerAsrException) rethrow; |
| 79 | + throw const ParaformerAsrException(0, '语音服务握手超时'); | ||
| 76 | } | 80 | } |
| 77 | } | 81 | } |
| 78 | 82 | ||
| 79 | - /// 开始转发 PCM 音频流。按腾讯云协议要求,以 200ms/6400 字节为 | ||
| 80 | - /// 一个数据包发送;音频流结束时自动发送 `{"type":"end"}`。 | 83 | + /// 开始转发 PCM 音频流。按协议以 200ms/6400 字节为一个数据包发送; |
| 84 | + /// 音频流结束时自动发送 finish-task。 | ||
| 81 | void startForwarding(Stream<Uint8List> audio) { | 85 | void startForwarding(Stream<Uint8List> audio) { |
| 82 | _audioSub = audio.listen( | 86 | _audioSub = audio.listen( |
| 83 | (Uint8List chunk) => _pcmBuffer.addAll(chunk), | 87 | (Uint8List chunk) => _pcmBuffer.addAll(chunk), |
| @@ -109,7 +113,14 @@ class TencentAsrClient { | @@ -109,7 +113,14 @@ class TencentAsrClient { | ||
| 109 | _pcmBuffer.removeRange(0, length); | 113 | _pcmBuffer.removeRange(0, length); |
| 110 | _send(frame); | 114 | _send(frame); |
| 111 | } | 115 | } |
| 112 | - _sendText(jsonEncode(<String, String>{'type': 'end'})); | 116 | + _sendText(jsonEncode(<String, Object>{ |
| 117 | + 'header': <String, Object>{ | ||
| 118 | + 'action': 'finish-task', | ||
| 119 | + 'task_id': _taskId, | ||
| 120 | + 'streaming': 'duplex', | ||
| 121 | + }, | ||
| 122 | + 'payload': <String, Object>{'input': <String, Object>{}}, | ||
| 123 | + })); | ||
| 113 | } | 124 | } |
| 114 | 125 | ||
| 115 | /// 停止后等待最终识别结果。 | 126 | /// 停止后等待最终识别结果。 |
| @@ -120,8 +131,8 @@ class TencentAsrClient { | @@ -120,8 +131,8 @@ class TencentAsrClient { | ||
| 120 | return await result.future.timeout(const Duration(seconds: 6)); | 131 | return await result.future.timeout(const Duration(seconds: 6)); |
| 121 | } on TimeoutException { | 132 | } on TimeoutException { |
| 122 | return _combinedText(); | 133 | return _combinedText(); |
| 123 | - } on TencentAsrException catch (error) { | ||
| 124 | - debugPrint('Tencent ASR error: $error'); | 134 | + } on ParaformerAsrException catch (error) { |
| 135 | + debugPrint('Paraformer ASR error: $error'); | ||
| 125 | return null; | 136 | return null; |
| 126 | } finally { | 137 | } finally { |
| 127 | await _teardown(); | 138 | await _teardown(); |
| @@ -145,48 +156,75 @@ class TencentAsrClient { | @@ -145,48 +156,75 @@ class TencentAsrClient { | ||
| 145 | _onPartial = callback; | 156 | _onPartial = callback; |
| 146 | } | 157 | } |
| 147 | 158 | ||
| 159 | + Map<String, Object> _runTaskPayload() => <String, Object>{ | ||
| 160 | + 'header': <String, Object>{ | ||
| 161 | + 'action': 'run-task', | ||
| 162 | + 'task_id': _taskId, | ||
| 163 | + 'streaming': 'duplex', | ||
| 164 | + }, | ||
| 165 | + 'payload': <String, Object>{ | ||
| 166 | + 'task_group': 'audio', | ||
| 167 | + 'task': 'asr', | ||
| 168 | + 'function': 'recognition', | ||
| 169 | + 'model': model, | ||
| 170 | + 'parameters': <String, Object>{ | ||
| 171 | + 'format': 'pcm', | ||
| 172 | + 'sample_rate': 16000, | ||
| 173 | + 'language_hints': <String>['zh'], | ||
| 174 | + 'disfluency_removal_enabled': false, | ||
| 175 | + 'semantic_punctuation_enabled': false, | ||
| 176 | + 'max_sentence_silence': 600, | ||
| 177 | + 'punctuation_prediction_enabled': true, | ||
| 178 | + 'inverse_text_normalization_enabled': true, | ||
| 179 | + }, | ||
| 180 | + 'input': <String, Object>{}, | ||
| 181 | + }, | ||
| 182 | + }; | ||
| 183 | + | ||
| 148 | void _onMessage(dynamic message) { | 184 | void _onMessage(dynamic message) { |
| 185 | + if (message is! String) return; | ||
| 149 | try { | 186 | try { |
| 150 | final Map<String, dynamic> json = | 187 | final Map<String, dynamic> json = |
| 151 | - jsonDecode(message.toString()) as Map<String, dynamic>; | ||
| 152 | - final int? code = json['code'] as int?; | ||
| 153 | - if (code != null && code != 0) { | ||
| 154 | - _fail( | ||
| 155 | - TencentAsrException( | ||
| 156 | - code, | ||
| 157 | - json['message']?.toString() ?? '语音识别失败', | ||
| 158 | - ), | ||
| 159 | - ); | ||
| 160 | - return; | ||
| 161 | - } | ||
| 162 | - | ||
| 163 | - if (!_handshakeDone) { | ||
| 164 | - _handshakeDone = true; | ||
| 165 | - if (_handshake != null && !_handshake!.isCompleted) { | ||
| 166 | - _handshake!.complete(); | ||
| 167 | - } | ||
| 168 | - } | ||
| 169 | - | ||
| 170 | - final Map<String, dynamic>? result = | ||
| 171 | - json['result'] as Map<String, dynamic>?; | ||
| 172 | - if (result != null) { | ||
| 173 | - final int? sliceType = result['slice_type'] as int?; | ||
| 174 | - final String text = (result['voice_text_str'] ?? '').toString(); | ||
| 175 | - if (sliceType == 2) { | ||
| 176 | - final int index = (result['index'] as int?) ?? _sentences.length; | ||
| 177 | - while (_sentences.length <= index) { | ||
| 178 | - _sentences.add(''); | 188 | + jsonDecode(message) as Map<String, dynamic>; |
| 189 | + final Map<String, dynamic>? header = | ||
| 190 | + json['header'] as Map<String, dynamic>?; | ||
| 191 | + final String? event = header?['event'] as String?; | ||
| 192 | + switch (event) { | ||
| 193 | + case 'task-started': | ||
| 194 | + if (_handshake != null && !_handshake!.isCompleted) { | ||
| 195 | + _handshake!.complete(); | ||
| 179 | } | 196 | } |
| 180 | - _sentences[index] = text; | ||
| 181 | - _partial = ''; | ||
| 182 | - } else if (sliceType == 1) { | ||
| 183 | - _partial = text; | ||
| 184 | - } | ||
| 185 | - _onPartial?.call(_combinedText()); | ||
| 186 | - } | ||
| 187 | - | ||
| 188 | - if (json['final'] == 1) { | ||
| 189 | - _completeResult(); | 197 | + return; |
| 198 | + case 'result-generated': | ||
| 199 | + final Map<String, dynamic>? payload = | ||
| 200 | + json['payload'] as Map<String, dynamic>?; | ||
| 201 | + final Map<String, dynamic>? output = | ||
| 202 | + payload?['output'] as Map<String, dynamic>?; | ||
| 203 | + final Map<String, dynamic>? sentence = | ||
| 204 | + output?['sentence'] as Map<String, dynamic>?; | ||
| 205 | + if (sentence == null) return; | ||
| 206 | + if (sentence['heartbeat'] == true) return; | ||
| 207 | + final String text = (sentence['text'] ?? '').toString(); | ||
| 208 | + final bool sentenceEnd = sentence['sentence_end'] == true; | ||
| 209 | + if (sentenceEnd) { | ||
| 210 | + _sentences.add(text); | ||
| 211 | + _partial = ''; | ||
| 212 | + } else { | ||
| 213 | + _partial = text; | ||
| 214 | + } | ||
| 215 | + _onPartial?.call(_combinedText()); | ||
| 216 | + return; | ||
| 217 | + case 'task-finished': | ||
| 218 | + _completeResult(); | ||
| 219 | + return; | ||
| 220 | + case 'task-failed': | ||
| 221 | + _fail( | ||
| 222 | + ParaformerAsrException( | ||
| 223 | + 0, | ||
| 224 | + (header?['error_message'] ?? '语音识别失败').toString(), | ||
| 225 | + ), | ||
| 226 | + ); | ||
| 227 | + return; | ||
| 190 | } | 228 | } |
| 191 | } catch (_) { | 229 | } catch (_) { |
| 192 | // 忽略无法解析的单条消息。 | 230 | // 忽略无法解析的单条消息。 |
| @@ -243,85 +281,41 @@ class TencentAsrClient { | @@ -243,85 +281,41 @@ class TencentAsrClient { | ||
| 243 | } catch (_) {} | 281 | } catch (_) {} |
| 244 | } | 282 | } |
| 245 | 283 | ||
| 246 | - Uri _buildUri(String voiceId) { | ||
| 247 | - final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; | ||
| 248 | - final Map<String, String> params = <String, String>{ | ||
| 249 | - 'secretid': secretId, | ||
| 250 | - 'timestamp': '$now', | ||
| 251 | - 'expired': '${now + 86400}', | ||
| 252 | - 'nonce': '${_randomInt(999999999)}', | ||
| 253 | - 'engine_model_type': engineModelType, | ||
| 254 | - 'voice_id': voiceId, | ||
| 255 | - 'voice_format': '1', | ||
| 256 | - 'needvad': '1', | ||
| 257 | - 'filter_modal': '1', | ||
| 258 | - 'filter_punc': '1', | ||
| 259 | - 'convert_num_mode': '1', | ||
| 260 | - }; | ||
| 261 | - | ||
| 262 | - final List<MapEntry<String, String>> sorted = params.entries.toList() | ||
| 263 | - ..sort((MapEntry<String, String> a, MapEntry<String, String> b) => | ||
| 264 | - a.key.compareTo(b.key)); | ||
| 265 | - | ||
| 266 | - final String canonical = sorted | ||
| 267 | - .map((MapEntry<String, String> e) => '${e.key}=${e.value}') | ||
| 268 | - .join('&'); | ||
| 269 | - final String signText = 'asr.cloud.tencent.com/asr/v2/$appId?$canonical'; | ||
| 270 | - | ||
| 271 | - final List<int> digest = Hmac(sha1, utf8.encode(secretKey)) | ||
| 272 | - .convert(utf8.encode(signText)) | ||
| 273 | - .bytes; | ||
| 274 | - final String signature = base64.encode(digest); | ||
| 275 | - | ||
| 276 | - final String query = sorted | ||
| 277 | - .map((MapEntry<String, String> e) => | ||
| 278 | - '${e.key}=${Uri.encodeQueryComponent(e.value)}') | ||
| 279 | - .join('&'); | ||
| 280 | - | ||
| 281 | - return Uri.parse( | ||
| 282 | - 'wss://asr.cloud.tencent.com/asr/v2/$appId?$query' | ||
| 283 | - '&signature=${Uri.encodeQueryComponent(signature)}', | ||
| 284 | - ); | ||
| 285 | - } | ||
| 286 | - | ||
| 287 | - String _newVoiceId() { | ||
| 288 | - final int now = DateTime.now().microsecondsSinceEpoch; | ||
| 289 | - final int rand = _randomInt(0x7fffffff); | ||
| 290 | - return 'voice_${now}_$rand'; | ||
| 291 | - } | ||
| 292 | - | ||
| 293 | - int _randomInt(int max) { | ||
| 294 | - final int seed = DateTime.now().microsecondsSinceEpoch; | ||
| 295 | - return (seed % max).abs() + 1; | 284 | + String _newUuidV4() { |
| 285 | + final Random random = Random.secure(); | ||
| 286 | + final List<int> bytes = List<int>.generate(16, (_) => random.nextInt(256)); | ||
| 287 | + bytes[6] = (bytes[6] & 0x0f) | 0x40; | ||
| 288 | + bytes[8] = (bytes[8] & 0x3f) | 0x80; | ||
| 289 | + final String hex = | ||
| 290 | + bytes.map((int byte) => byte.toRadixString(16).padLeft(2, '0')).join(); | ||
| 291 | + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' | ||
| 292 | + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-' | ||
| 293 | + '${hex.substring(20)}'; | ||
| 296 | } | 294 | } |
| 297 | } | 295 | } |
| 298 | 296 | ||
| 299 | -class TencentAsrException implements Exception { | ||
| 300 | - const TencentAsrException(this.code, this.message); | 297 | +class ParaformerAsrException implements Exception { |
| 298 | + const ParaformerAsrException(this.code, this.message); | ||
| 301 | 299 | ||
| 302 | final int code; | 300 | final int code; |
| 303 | final String message; | 301 | final String message; |
| 304 | 302 | ||
| 305 | @override | 303 | @override |
| 306 | - String toString() => 'TencentAsrException($code, $message)'; | 304 | + String toString() => 'ParaformerAsrException($code, $message)'; |
| 307 | } | 305 | } |
| 308 | 306 | ||
| 309 | -/// 负责录音并把 PCM 流交给 [TencentAsrClient] 的会话封装。 | ||
| 310 | -class TencentAsrSession { | ||
| 311 | - TencentAsrSession({ | ||
| 312 | - required this.secretId, | ||
| 313 | - required this.secretKey, | ||
| 314 | - required this.appId, | 307 | +/// 负责录音并把 PCM 流交给 [ParaformerAsrClient] 的会话封装。 |
| 308 | +class ParaformerAsrSession { | ||
| 309 | + ParaformerAsrSession({ | ||
| 310 | + required this.apiKey, | ||
| 315 | this.onPartial, | 311 | this.onPartial, |
| 316 | }); | 312 | }); |
| 317 | 313 | ||
| 318 | - final String secretId; | ||
| 319 | - final String secretKey; | ||
| 320 | - final String appId; | 314 | + final String apiKey; |
| 321 | final void Function(String text)? onPartial; | 315 | final void Function(String text)? onPartial; |
| 322 | 316 | ||
| 323 | final AudioRecorder _recorder = AudioRecorder(); | 317 | final AudioRecorder _recorder = AudioRecorder(); |
| 324 | - TencentAsrClient? _client; | 318 | + ParaformerAsrClient? _client; |
| 325 | bool _recording = false; | 319 | bool _recording = false; |
| 326 | 320 | ||
| 327 | /// 检查权限、建立连接并完成云端握手,此时尚未开始录音。 | 321 | /// 检查权限、建立连接并完成云端握手,此时尚未开始录音。 |
| @@ -329,23 +323,19 @@ class TencentAsrSession { | @@ -329,23 +323,19 @@ class TencentAsrSession { | ||
| 329 | if (!await _recorder.hasPermission()) { | 323 | if (!await _recorder.hasPermission()) { |
| 330 | final PermissionStatus status = await Permission.microphone.request(); | 324 | final PermissionStatus status = await Permission.microphone.request(); |
| 331 | if (!status.isGranted) { | 325 | if (!status.isGranted) { |
| 332 | - throw const TencentAsrException(0, '未获得麦克风权限'); | 326 | + throw const ParaformerAsrException(0, '未获得麦克风权限'); |
| 333 | } | 327 | } |
| 334 | } | 328 | } |
| 335 | - final TencentAsrClient client = TencentAsrClient( | ||
| 336 | - secretId: secretId, | ||
| 337 | - secretKey: secretKey, | ||
| 338 | - appId: appId, | ||
| 339 | - ); | 329 | + final ParaformerAsrClient client = ParaformerAsrClient(apiKey: apiKey); |
| 340 | _client = client; | 330 | _client = client; |
| 341 | await client.connect(); | 331 | await client.connect(); |
| 342 | } | 332 | } |
| 343 | 333 | ||
| 344 | /// 开始录音并发送音频。 | 334 | /// 开始录音并发送音频。 |
| 345 | Future<void> startAudio() async { | 335 | Future<void> startAudio() async { |
| 346 | - final TencentAsrClient? client = _client; | 336 | + final ParaformerAsrClient? client = _client; |
| 347 | if (client == null) { | 337 | if (client == null) { |
| 348 | - throw const TencentAsrException(0, '语音会话未建立'); | 338 | + throw const ParaformerAsrException(0, '语音会话未建立'); |
| 349 | } | 339 | } |
| 350 | 340 | ||
| 351 | final Stream<Uint8List> audio = await _recorder.startStream( | 341 | final Stream<Uint8List> audio = await _recorder.startStream( |
| @@ -379,7 +369,7 @@ class TencentAsrSession { | @@ -379,7 +369,7 @@ class TencentAsrSession { | ||
| 379 | try { | 369 | try { |
| 380 | await _recorder.stop(); | 370 | await _recorder.stop(); |
| 381 | } catch (_) {} | 371 | } catch (_) {} |
| 382 | - final TencentAsrClient? client = _client; | 372 | + final ParaformerAsrClient? client = _client; |
| 383 | _client = null; | 373 | _client = null; |
| 384 | try { | 374 | try { |
| 385 | await client?.cancel(); | 375 | await client?.cancel(); |
lib/pages/agent/qwen_ocr_client.dart
0 → 100644
| 1 | +import 'dart:convert'; | ||
| 2 | +import 'dart:typed_data'; | ||
| 3 | + | ||
| 4 | +import 'package:dio/dio.dart'; | ||
| 5 | + | ||
| 6 | +import 'package:one_key/pages/agent/reservation_image_parser.dart'; | ||
| 7 | + | ||
| 8 | +/// 阿里云百炼(DashScope)千问视觉模型客户端。 | ||
| 9 | +/// | ||
| 10 | +/// 使用 OpenAI 兼容模式,让 qwen-vl-plus 直接从图片中提取预订字段, | ||
| 11 | +/// 并返回结构化 JSON,匹配 [ReservationImageParseResult] 的字段。 | ||
| 12 | +class QwenOcrClient { | ||
| 13 | + QwenOcrClient({ | ||
| 14 | + required this.apiKey, | ||
| 15 | + this.model = 'qwen-vl-plus', | ||
| 16 | + }); | ||
| 17 | + | ||
| 18 | + final String apiKey; | ||
| 19 | + final String model; | ||
| 20 | + | ||
| 21 | + static const String _endpoint = | ||
| 22 | + 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'; | ||
| 23 | + | ||
| 24 | + final Dio _dio = Dio( | ||
| 25 | + BaseOptions( | ||
| 26 | + connectTimeout: const Duration(seconds: 20), | ||
| 27 | + receiveTimeout: const Duration(seconds: 60), | ||
| 28 | + responseType: ResponseType.json, | ||
| 29 | + ), | ||
| 30 | + ); | ||
| 31 | + | ||
| 32 | + String _systemPrompt() { | ||
| 33 | + final DateTime now = DateTime.now(); | ||
| 34 | + final String today = '${now.year}-${now.month.toString().padLeft(2, '0')}-' | ||
| 35 | + '${now.day.toString().padLeft(2, '0')}'; | ||
| 36 | + return '今天是 $today。' | ||
| 37 | + '你是餐厅预订信息提取助手。请仔细识别图片中的文字,并从微信聊天记录、' | ||
| 38 | + '预订截图或手写信息中提取餐厅预订字段。' | ||
| 39 | + '只输出一个 JSON 对象,不要输出任何解释或 Markdown 代码块标记。\n\n' | ||
| 40 | + 'JSON 字段说明:\n' | ||
| 41 | + '- name: 预订人姓名,去掉“先生/女士”等称谓\n' | ||
| 42 | + '- sex: 称谓,只能是 先生、女士、公司、贵宾 之一,没有则为空字符串\n' | ||
| 43 | + '- phone: 手机号,没有则为空字符串\n' | ||
| 44 | + '- party: 用餐人数,纯数字字符串,没有则为空字符串\n' | ||
| 45 | + '- date: 预订日期,格式 YYYY-MM-DD;必须是今天或未来日期;只有月日或星期时,按今天 $today 推断为下一个未来日期并补全年份\n' | ||
| 46 | + '- meal: 用餐时段,只能是 早餐、午餐、晚餐 之一;根据时间和上下文推断,没有则为空字符串\n' | ||
| 47 | + '- time: 到店时间,格式 HH:mm,没有则为空字符串\n' | ||
| 48 | + '- table: 桌位号或包间号,没有则为空字符串\n' | ||
| 49 | + '- remark: 备注/特殊要求,没有则为空字符串\n' | ||
| 50 | + '- rawText: 图片中识别出的完整文字,按原顺序用换行拼接\n\n' | ||
| 51 | + '输出示例:\n' | ||
| 52 | + '{"name":"王先生","sex":"先生","phone":"13800138000",' | ||
| 53 | + '"party":"8","date":"$today","meal":"晚餐",' | ||
| 54 | + '"time":"18:30","table":"A8","remark":"靠窗",' | ||
| 55 | + '"rawText":"王先生 13800138000\\n9月25日晚餐 8人"}'; | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + /// 从图片中提取预订字段;结构化识别失败时自动降级为正则解析。 | ||
| 59 | + Future<ReservationImageParseResult> parseReservation( | ||
| 60 | + Uint8List imageBytes, | ||
| 61 | + ) async { | ||
| 62 | + if (imageBytes.isEmpty) { | ||
| 63 | + throw const QwenOcrException(0, '图片内容为空'); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + final String content = await _chat(imageBytes); | ||
| 67 | + final Map<String, dynamic>? json = _parseJsonObject(content); | ||
| 68 | + | ||
| 69 | + final ReservationImageParseResult parsed = | ||
| 70 | + ReservationImageParser.parse(content); | ||
| 71 | + | ||
| 72 | + if (json == null) { | ||
| 73 | + return parsed; | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + String field(String key) { | ||
| 77 | + final dynamic value = json[key]; | ||
| 78 | + if (value == null) return ''; | ||
| 79 | + return value.toString().trim(); | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + final String rawText = | ||
| 83 | + field('rawText').isNotEmpty ? field('rawText') : content; | ||
| 84 | + final ReservationImageParseResult regexParsed = | ||
| 85 | + ReservationImageParser.parse(rawText); | ||
| 86 | + | ||
| 87 | + String firstNonEmpty(String primary, String fallback) { | ||
| 88 | + return primary.isNotEmpty ? primary : fallback; | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + return ReservationImageParseResult( | ||
| 92 | + name: firstNonEmpty(field('name'), regexParsed.name), | ||
| 93 | + sex: _normalizeSex(firstNonEmpty(field('sex'), regexParsed.sex)), | ||
| 94 | + phone: firstNonEmpty(field('phone'), regexParsed.phone), | ||
| 95 | + party: firstNonEmpty(field('party'), regexParsed.party), | ||
| 96 | + date: firstNonEmpty(field('date'), regexParsed.date), | ||
| 97 | + meal: _normalizeMeal(firstNonEmpty(field('meal'), regexParsed.meal)), | ||
| 98 | + time: firstNonEmpty(field('time'), regexParsed.time), | ||
| 99 | + table: firstNonEmpty(field('table'), regexParsed.table), | ||
| 100 | + remark: firstNonEmpty(field('remark'), regexParsed.remark), | ||
| 101 | + rawText: rawText, | ||
| 102 | + ); | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + Future<String> _chat(Uint8List imageBytes) async { | ||
| 106 | + final String base64 = base64Encode(imageBytes); | ||
| 107 | + final String mime = _inferMimeType(imageBytes); | ||
| 108 | + | ||
| 109 | + final Response<Map<String, dynamic>> response; | ||
| 110 | + try { | ||
| 111 | + response = await _dio.post<Map<String, dynamic>>( | ||
| 112 | + _endpoint, | ||
| 113 | + data: <String, dynamic>{ | ||
| 114 | + 'model': model, | ||
| 115 | + 'temperature': 0.1, | ||
| 116 | + 'messages': <Map<String, dynamic>>[ | ||
| 117 | + <String, dynamic>{ | ||
| 118 | + 'role': 'system', | ||
| 119 | + 'content': _systemPrompt(), | ||
| 120 | + }, | ||
| 121 | + <String, dynamic>{ | ||
| 122 | + 'role': 'user', | ||
| 123 | + 'content': <Map<String, dynamic>>[ | ||
| 124 | + <String, dynamic>{ | ||
| 125 | + 'type': 'image_url', | ||
| 126 | + 'image_url': <String, String>{ | ||
| 127 | + 'url': 'data:$mime;base64,$base64', | ||
| 128 | + }, | ||
| 129 | + }, | ||
| 130 | + const <String, String>{ | ||
| 131 | + 'type': 'text', | ||
| 132 | + 'text': '请提取图片中的餐厅预订信息并返回 JSON。', | ||
| 133 | + }, | ||
| 134 | + ], | ||
| 135 | + }, | ||
| 136 | + ], | ||
| 137 | + }, | ||
| 138 | + options: Options( | ||
| 139 | + headers: <String, String>{ | ||
| 140 | + 'Authorization': 'Bearer $apiKey', | ||
| 141 | + 'Content-Type': 'application/json', | ||
| 142 | + }, | ||
| 143 | + ), | ||
| 144 | + ); | ||
| 145 | + } on DioException catch (error) { | ||
| 146 | + throw QwenOcrException( | ||
| 147 | + error.response?.statusCode ?? 0, | ||
| 148 | + _friendlyDioError(error), | ||
| 149 | + ); | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + final Map<String, dynamic>? data = response.data; | ||
| 153 | + if (data == null) { | ||
| 154 | + throw const QwenOcrException(0, '图片识别返回数据异常'); | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + final dynamic error = data['error']; | ||
| 158 | + if (error is Map) { | ||
| 159 | + throw QwenOcrException( | ||
| 160 | + error['code']?.toString() ?? '0', | ||
| 161 | + error['message']?.toString() ?? '图片识别失败', | ||
| 162 | + ); | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + final List<dynamic>? choices = data['choices'] as List<dynamic>?; | ||
| 166 | + final dynamic message = | ||
| 167 | + choices != null && choices.isNotEmpty | ||
| 168 | + ? (choices.first as Map<String, dynamic>)['message'] | ||
| 169 | + : null; | ||
| 170 | + if (message is! Map) { | ||
| 171 | + throw const QwenOcrException(0, '图片识别未返回有效内容'); | ||
| 172 | + } | ||
| 173 | + | ||
| 174 | + final dynamic content = message['content']; | ||
| 175 | + if (content is String) { | ||
| 176 | + return content.trim(); | ||
| 177 | + } | ||
| 178 | + if (content is List) { | ||
| 179 | + final StringBuffer buffer = StringBuffer(); | ||
| 180 | + for (final dynamic item in content) { | ||
| 181 | + if (item is Map) { | ||
| 182 | + final dynamic text = item['text']; | ||
| 183 | + if (text != null) { | ||
| 184 | + buffer.write(text.toString()); | ||
| 185 | + } | ||
| 186 | + } | ||
| 187 | + } | ||
| 188 | + return buffer.toString().trim(); | ||
| 189 | + } | ||
| 190 | + return ''; | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + Map<String, dynamic>? _parseJsonObject(String content) { | ||
| 194 | + final String trimmed = content.trim(); | ||
| 195 | + if (trimmed.isEmpty) return null; | ||
| 196 | + | ||
| 197 | + final int start = trimmed.indexOf('{'); | ||
| 198 | + final int end = trimmed.lastIndexOf('}'); | ||
| 199 | + if (start < 0 || end <= start) return null; | ||
| 200 | + | ||
| 201 | + try { | ||
| 202 | + final dynamic decoded = jsonDecode(trimmed.substring(start, end + 1)); | ||
| 203 | + if (decoded is Map<String, dynamic>) { | ||
| 204 | + return decoded; | ||
| 205 | + } | ||
| 206 | + } catch (_) { | ||
| 207 | + return null; | ||
| 208 | + } | ||
| 209 | + return null; | ||
| 210 | + } | ||
| 211 | + | ||
| 212 | + String _normalizeSex(String value) { | ||
| 213 | + if (value == '男') return '先生'; | ||
| 214 | + if (value == '女' || value == '小姐') return '女士'; | ||
| 215 | + return value; | ||
| 216 | + } | ||
| 217 | + | ||
| 218 | + String _normalizeMeal(String value) { | ||
| 219 | + if (value.contains('早') || value.contains('上午') || value.contains('早上')) { | ||
| 220 | + return '早餐'; | ||
| 221 | + } | ||
| 222 | + if (value.contains('晚') || | ||
| 223 | + value.contains('夜') || | ||
| 224 | + value.contains('宵') || | ||
| 225 | + value.contains('下午')) { | ||
| 226 | + return '晚餐'; | ||
| 227 | + } | ||
| 228 | + if (value.contains('午') || value.contains('中')) { | ||
| 229 | + return '午餐'; | ||
| 230 | + } | ||
| 231 | + return value; | ||
| 232 | + } | ||
| 233 | + | ||
| 234 | + String _friendlyDioError(DioException error) { | ||
| 235 | + final int? statusCode = error.response?.statusCode; | ||
| 236 | + if (statusCode == 401) return '千问 API Key 无效,请检查百炼密钥'; | ||
| 237 | + if (statusCode == 429) return '请求太频繁或额度不足,请稍后重试'; | ||
| 238 | + if (error.type == DioExceptionType.connectionTimeout || | ||
| 239 | + error.type == DioExceptionType.receiveTimeout) { | ||
| 240 | + return '识别请求超时,请检查网络后重试'; | ||
| 241 | + } | ||
| 242 | + return '图片识别网络请求失败,请检查网络'; | ||
| 243 | + } | ||
| 244 | + | ||
| 245 | + String _inferMimeType(Uint8List bytes) { | ||
| 246 | + if (bytes.length >= 3 && | ||
| 247 | + bytes[0] == 0xFF && | ||
| 248 | + bytes[1] == 0xD8 && | ||
| 249 | + bytes[2] == 0xFF) { | ||
| 250 | + return 'image/jpeg'; | ||
| 251 | + } | ||
| 252 | + if (bytes.length >= 8 && | ||
| 253 | + bytes[0] == 0x89 && | ||
| 254 | + bytes[1] == 0x50 && | ||
| 255 | + bytes[2] == 0x4E && | ||
| 256 | + bytes[3] == 0x47) { | ||
| 257 | + return 'image/png'; | ||
| 258 | + } | ||
| 259 | + if (bytes.length >= 6 && | ||
| 260 | + bytes[0] == 0x47 && | ||
| 261 | + bytes[1] == 0x49 && | ||
| 262 | + bytes[2] == 0x46 && | ||
| 263 | + bytes[3] == 0x38) { | ||
| 264 | + return 'image/gif'; | ||
| 265 | + } | ||
| 266 | + if (bytes.length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D) { | ||
| 267 | + return 'image/bmp'; | ||
| 268 | + } | ||
| 269 | + return 'image/jpeg'; | ||
| 270 | + } | ||
| 271 | +} | ||
| 272 | + | ||
| 273 | +class QwenOcrException implements Exception { | ||
| 274 | + const QwenOcrException(this.code, this.message); | ||
| 275 | + | ||
| 276 | + final Object code; | ||
| 277 | + final String message; | ||
| 278 | + | ||
| 279 | + @override | ||
| 280 | + String toString() => 'QwenOcrException($code, $message)'; | ||
| 281 | +} |
| 1 | +import 'package:flutter/foundation.dart'; | ||
| 2 | + | ||
| 3 | +/// 承接从其他 App(如微信)分享进来的预订截图。 | ||
| 4 | +/// | ||
| 5 | +/// 分享回调把图片文件路径写入 [pendingImage],经营助手页监听后 | ||
| 6 | +/// 自动走「识别预订信息 → 确认预订」流程。 | ||
| 7 | +class SharedReservationReceiver { | ||
| 8 | + SharedReservationReceiver._(); | ||
| 9 | + | ||
| 10 | + static final SharedReservationReceiver instance = | ||
| 11 | + SharedReservationReceiver._(); | ||
| 12 | + | ||
| 13 | + /// 待处理的分享图片路径;为 null 表示当前没有待识别图片。 | ||
| 14 | + final ValueNotifier<String?> pendingImage = ValueNotifier<String?>(null); | ||
| 15 | + | ||
| 16 | + /// 设置待识别图片,并通知监听者。 | ||
| 17 | + void acceptImage(String path) { | ||
| 18 | + pendingImage.value = path; | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + /// 标记该图片已被消费。 | ||
| 22 | + void consumeImage() { | ||
| 23 | + if (pendingImage.value != null) { | ||
| 24 | + pendingImage.value = null; | ||
| 25 | + } | ||
| 26 | + } | ||
| 27 | +} |
| @@ -1590,6 +1590,14 @@ packages: | @@ -1590,6 +1590,14 @@ packages: | ||
| 1590 | url: "https://pub.flutter-io.cn" | 1590 | url: "https://pub.flutter-io.cn" |
| 1591 | source: hosted | 1591 | source: hosted |
| 1592 | version: "2.2.3" | 1592 | version: "2.2.3" |
| 1593 | + receive_sharing_intent: | ||
| 1594 | + dependency: "direct main" | ||
| 1595 | + description: | ||
| 1596 | + name: receive_sharing_intent | ||
| 1597 | + sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 | ||
| 1598 | + url: "https://pub.flutter-io.cn" | ||
| 1599 | + source: hosted | ||
| 1600 | + version: "1.8.1" | ||
| 1593 | record: | 1601 | record: |
| 1594 | dependency: "direct main" | 1602 | dependency: "direct main" |
| 1595 | description: | 1603 | description: |
| @@ -74,6 +74,8 @@ dependencies: | @@ -74,6 +74,8 @@ dependencies: | ||
| 74 | cached_network_image: ^3.4.1 | 74 | cached_network_image: ^3.4.1 |
| 75 | # websocket | 75 | # websocket |
| 76 | web_socket_channel: ^2.4.0 | 76 | web_socket_channel: ^2.4.0 |
| 77 | +# 接收外部App分享(微信截图等) | ||
| 78 | + receive_sharing_intent: ^1.8.1 | ||
| 77 | # sqlite数据库 | 79 | # sqlite数据库 |
| 78 | sqflite: ^2.2.8+4 | 80 | sqflite: ^2.2.8+4 |
| 79 | # Bugly | 81 | # Bugly |
-
Please register or login to post a comment