|
|
|
import 'dart:async';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/services.dart';
|
|
...
|
...
|
@@ -6,9 +7,10 @@ import 'package:image_picker/image_picker.dart'; |
|
|
|
import 'package:one_key/app.dart';
|
|
|
|
import 'package:one_key/models/notebook_detail.dart';
|
|
|
|
import 'package:one_key/pages/agent/agent_chat_store.dart';
|
|
|
|
import 'package:one_key/pages/agent/baidu_ocr_client.dart';
|
|
|
|
import 'package:one_key/pages/agent/qwen_ocr_client.dart';
|
|
|
|
import 'package:one_key/pages/agent/reservation_image_parser.dart';
|
|
|
|
import 'package:one_key/pages/agent/tencent_asr_client.dart';
|
|
|
|
import 'package:one_key/pages/agent/shared_reservation_receiver.dart';
|
|
|
|
import 'package:one_key/pages/agent/paraformer_asr_client.dart';
|
|
|
|
import 'package:one_key/provider/user_model.dart';
|
|
|
|
import 'package:one_key/res/colors.dart';
|
|
|
|
import 'package:one_key/route/navigator_util.dart';
|
|
...
|
...
|
@@ -75,11 +77,8 @@ class AgentChatPage extends StatefulWidget { |
|
|
|
class _AgentChatPageState extends State<AgentChatPage> {
|
|
|
|
static const String _greeting = '你好,我是经营助手。今天想问点什么?比如:“今天营业额多少?”';
|
|
|
|
// TODO: 正式上线时应由服务端下发密钥,避免客户端泄露。
|
|
|
|
static const String _tencentSecretId = 'AKID9Vgy8FEC5qSxLcllVCmXzJWaVSI0Z2Nb';
|
|
|
|
static const String _tencentSecretKey = 'c2c5MTOBo5d9XilhaLzVSG4DE7qRuCJF';
|
|
|
|
static const String _tencentAppId = '1308375824';
|
|
|
|
static const String _baiduApiKey = 'ChpdwH1VCou4B7eWF1hBqmn6';
|
|
|
|
static const String _baiduSecretKey = 'ayognpdpOYkoHQmBxjQJQzvjnSpqLA6w';
|
|
|
|
static const String _qwenApiKey =
|
|
|
|
'sk-ws-H.PILIHPE.GQc0.MEUCIFS4lSx9mCvznDxgNHzPthawcyEIlr3W8eTtquLAalb3AiEAivaBkCp122lHoIFBiAeWYaUsIyrVOUbUsqQnPj8ApIs';
|
|
|
|
static const MethodChannel _speechChannel = MethodChannel('com.naji/book');
|
|
|
|
|
|
|
|
final ScrollController _scrollController = ScrollController();
|
|
...
|
...
|
@@ -98,7 +97,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
bool _historyLoading = true;
|
|
|
|
bool _usingNativeSpeech = false;
|
|
|
|
bool _usingWhisper = false;
|
|
|
|
bool _usingTencentAsr = false;
|
|
|
|
bool _usingCloudAsr = false;
|
|
|
|
bool _whisperModelReady = false;
|
|
|
|
bool _voicePressActive = false;
|
|
|
|
bool _imageProcessing = false;
|
|
...
|
...
|
@@ -114,8 +113,9 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
WhisperEngine? _whisperEngine;
|
|
|
|
WhisperStreamTask? _whisperTask;
|
|
|
|
StreamSubscription<WhisperStreamUpdate>? _whisperUpdateSub;
|
|
|
|
VoidCallback? _sharedImageListener;
|
|
|
|
Future<bool>? _whisperPrepareFuture;
|
|
|
|
TencentAsrSession? _tencentSession;
|
|
|
|
ParaformerAsrSession? _cloudAsrSession;
|
|
|
|
|
|
|
|
bool get _supportsWhisper =>
|
|
|
|
defaultTargetPlatform == TargetPlatform.android ||
|
|
...
|
...
|
@@ -142,6 +142,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
unawaited(_prewarmWhisperEngine());
|
|
|
|
}
|
|
|
|
_loadHistory();
|
|
|
|
_initSharedReservationListener();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
...
|
...
|
@@ -150,8 +151,14 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceTimer?.cancel();
|
|
|
|
_voiceRequestId++;
|
|
|
|
_whisperUpdateSub?.cancel();
|
|
|
|
final VoidCallback? sharedListener = _sharedImageListener;
|
|
|
|
_sharedImageListener = null;
|
|
|
|
if (sharedListener != null) {
|
|
|
|
SharedReservationReceiver.instance.pendingImage
|
|
|
|
.removeListener(sharedListener);
|
|
|
|
}
|
|
|
|
unawaited(_whisperTask?.cancel());
|
|
|
|
unawaited(_tencentSession?.cancel());
|
|
|
|
unawaited(_cloudAsrSession?.cancel());
|
|
|
|
try {
|
|
|
|
_whisperEngine?.dispose();
|
|
|
|
} catch (_) {}
|
|
...
|
...
|
@@ -188,8 +195,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
try {
|
|
|
|
final WhisperModelManager manager =
|
|
|
|
_whisperModels ??= WhisperModelManager();
|
|
|
|
final model =
|
|
|
|
await manager.findCatalogModel(WhisperModelCatalog.base);
|
|
|
|
final model = await manager.findCatalogModel(WhisperModelCatalog.base);
|
|
|
|
if (model == null || _whisperEngine != null) return;
|
|
|
|
final WhisperEngine engine = await WhisperEngine.load(model.path);
|
|
|
|
if (!mounted) {
|
|
...
|
...
|
@@ -370,7 +376,8 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
final String trimmed = text.trim();
|
|
|
|
if (trimmed.isEmpty) return false;
|
|
|
|
|
|
|
|
final ReservationImageParseResult result = ReservationImageParser.parse(trimmed);
|
|
|
|
final ReservationImageParseResult result =
|
|
|
|
ReservationImageParser.parse(trimmed);
|
|
|
|
if (!_isReservationText(result)) return false;
|
|
|
|
|
|
|
|
unawaited(_showReservationConfirm(_normalizeReservationDate(result)));
|
|
...
|
...
|
@@ -623,8 +630,14 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
kind: _CardKind.orderList,
|
|
|
|
title: '桌${table.group(1)} · 近期订单',
|
|
|
|
items: [
|
|
|
|
{'name': '#2026091718 · 桌${table.group(1)}', 'status': '已支付 ¥386'},
|
|
|
|
{'name': '#2026091721 · 桌${table.group(1)}', 'status': '待支付 ¥168'},
|
|
|
|
{
|
|
|
|
'name': '#2026091718 · 桌${table.group(1)}',
|
|
|
|
'status': '已支付 ¥386'
|
|
|
|
},
|
|
|
|
{
|
|
|
|
'name': '#2026091721 · 桌${table.group(1)}',
|
|
|
|
'status': '待支付 ¥168'
|
|
|
|
},
|
|
|
|
{'name': '#2026091723 · 桌${table.group(1)}', 'status': '已支付 ¥96'},
|
|
|
|
],
|
|
|
|
),
|
|
...
|
...
|
@@ -840,7 +853,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceTimer?.cancel();
|
|
|
|
|
|
|
|
if (_supportsWhisper) {
|
|
|
|
await _startTencentVoice(requestId);
|
|
|
|
await _startCloudVoice(requestId);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -848,9 +861,9 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
await _startLegacyVoice();
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 移动端优先使用腾讯云实时语音识别,失败时回退到离线 Whisper。
|
|
|
|
Future<void> _startTencentVoice(int requestId) async {
|
|
|
|
_usingTencentAsr = true;
|
|
|
|
/// 移动端优先使用百炼 Paraformer 实时语音识别,失败时回退到离线 Whisper。
|
|
|
|
Future<void> _startCloudVoice(int requestId) async {
|
|
|
|
_usingCloudAsr = true;
|
|
|
|
_usingWhisper = false;
|
|
|
|
setState(() {
|
|
|
|
_voiceStage = _VoiceStage.preparing;
|
|
...
|
...
|
@@ -859,24 +872,22 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceHint = '正在连接云端语音识别…';
|
|
|
|
});
|
|
|
|
|
|
|
|
final TencentAsrSession session = TencentAsrSession(
|
|
|
|
secretId: _tencentSecretId,
|
|
|
|
secretKey: _tencentSecretKey,
|
|
|
|
appId: _tencentAppId,
|
|
|
|
final ParaformerAsrSession session = ParaformerAsrSession(
|
|
|
|
apiKey: _qwenApiKey,
|
|
|
|
onPartial: (String text) {
|
|
|
|
if (!mounted || requestId != _voiceRequestId) return;
|
|
|
|
setState(() => _recognizedText = text);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
_tencentSession = session;
|
|
|
|
_cloudAsrSession = session;
|
|
|
|
|
|
|
|
try {
|
|
|
|
await session.prepare();
|
|
|
|
} catch (error) {
|
|
|
|
debugPrint('Tencent ASR prepare failed: $error');
|
|
|
|
debugPrint('Paraformer ASR prepare failed: $error');
|
|
|
|
if (!mounted || requestId != _voiceRequestId) return;
|
|
|
|
_tencentSession = null;
|
|
|
|
_usingTencentAsr = false;
|
|
|
|
_cloudAsrSession = null;
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
await session.dispose();
|
|
|
|
await _startWhisperVoice(requestId);
|
|
|
|
return;
|
|
...
|
...
|
@@ -888,8 +899,8 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
}
|
|
|
|
|
|
|
|
if (!_voicePressActive) {
|
|
|
|
_tencentSession = null;
|
|
|
|
_usingTencentAsr = false;
|
|
|
|
_cloudAsrSession = null;
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
await session.cancel();
|
|
|
|
if (mounted && requestId == _voiceRequestId) {
|
|
|
|
setState(() {
|
|
...
|
...
|
@@ -904,10 +915,10 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
try {
|
|
|
|
await session.startAudio();
|
|
|
|
} catch (error) {
|
|
|
|
debugPrint('Tencent ASR start failed: $error');
|
|
|
|
debugPrint('Paraformer ASR start failed: $error');
|
|
|
|
if (!mounted || requestId != _voiceRequestId) return;
|
|
|
|
_tencentSession = null;
|
|
|
|
_usingTencentAsr = false;
|
|
|
|
_cloudAsrSession = null;
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
await session.dispose();
|
|
|
|
await _startWhisperVoice(requestId);
|
|
|
|
return;
|
|
...
|
...
|
@@ -972,20 +983,20 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
await _startWhisperRecording(requestId);
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _stopTencentVoice(int requestId) async {
|
|
|
|
Future<void> _stopCloudVoice(int requestId) async {
|
|
|
|
_voiceTimer?.cancel();
|
|
|
|
final TencentAsrSession? session = _tencentSession;
|
|
|
|
final ParaformerAsrSession? session = _cloudAsrSession;
|
|
|
|
if (session == null) return;
|
|
|
|
|
|
|
|
String? text;
|
|
|
|
try {
|
|
|
|
text = await session.stop();
|
|
|
|
} catch (error) {
|
|
|
|
debugPrint('Tencent ASR stop failed: $error');
|
|
|
|
debugPrint('Paraformer ASR stop failed: $error');
|
|
|
|
}
|
|
|
|
unawaited(session.dispose());
|
|
|
|
_tencentSession = null;
|
|
|
|
_usingTencentAsr = false;
|
|
|
|
_cloudAsrSession = null;
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
if (!mounted || requestId != _voiceRequestId) return;
|
|
|
|
await _finishVoiceWithText(text);
|
|
|
|
}
|
|
...
|
...
|
@@ -1240,9 +1251,9 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
if (!mounted) return;
|
|
|
|
if (_voiceStage != _VoiceStage.recording) return;
|
|
|
|
|
|
|
|
if (_usingTencentAsr) {
|
|
|
|
if (_usingCloudAsr) {
|
|
|
|
final int requestId = _voiceRequestId;
|
|
|
|
await _stopTencentVoice(requestId);
|
|
|
|
await _stopCloudVoice(requestId);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -1308,8 +1319,8 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceTimer?.cancel();
|
|
|
|
_usingNativeSpeech = false;
|
|
|
|
_usingWhisper = false;
|
|
|
|
_usingTencentAsr = false;
|
|
|
|
_tencentSession = null;
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
_cloudAsrSession = null;
|
|
|
|
_whisperUpdateSub?.cancel();
|
|
|
|
_whisperUpdateSub = null;
|
|
|
|
_whisperTask = null;
|
|
...
|
...
|
@@ -1344,9 +1355,9 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceRequestId++;
|
|
|
|
_usingWhisper = false;
|
|
|
|
_usingNativeSpeech = false;
|
|
|
|
_usingTencentAsr = false;
|
|
|
|
unawaited(_tencentSession?.cancel());
|
|
|
|
_tencentSession = null;
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
unawaited(_cloudAsrSession?.cancel());
|
|
|
|
_cloudAsrSession = null;
|
|
|
|
_whisperUpdateSub?.cancel();
|
|
|
|
_whisperUpdateSub = null;
|
|
|
|
_whisperTask = null;
|
|
...
|
...
|
@@ -1367,10 +1378,10 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceTimer?.cancel();
|
|
|
|
_voiceRequestId++;
|
|
|
|
_usingWhisper = false;
|
|
|
|
if (_usingTencentAsr) {
|
|
|
|
_usingTencentAsr = false;
|
|
|
|
unawaited(_tencentSession?.cancel());
|
|
|
|
_tencentSession = null;
|
|
|
|
if (_usingCloudAsr) {
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
unawaited(_cloudAsrSession?.cancel());
|
|
|
|
_cloudAsrSession = null;
|
|
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
...
|
...
|
@@ -1384,10 +1395,10 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceTimer?.cancel();
|
|
|
|
_voiceRequestId++;
|
|
|
|
_usingWhisper = false;
|
|
|
|
if (_usingTencentAsr) {
|
|
|
|
_usingTencentAsr = false;
|
|
|
|
final TencentAsrSession? session = _tencentSession;
|
|
|
|
_tencentSession = null;
|
|
|
|
if (_usingCloudAsr) {
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
final ParaformerAsrSession? session = _cloudAsrSession;
|
|
|
|
_cloudAsrSession = null;
|
|
|
|
if (session != null) {
|
|
|
|
try {
|
|
|
|
await session.cancel();
|
|
...
|
...
|
@@ -1429,27 +1440,62 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
|
|
|
|
Future<void> _pickReservationImage() async {
|
|
|
|
if (_imageProcessing) return;
|
|
|
|
setState(() => _imageProcessing = true);
|
|
|
|
final XFile? file = await ImagePicker().pickImage(
|
|
|
|
source: ImageSource.gallery,
|
|
|
|
maxWidth: 1600,
|
|
|
|
imageQuality: 80,
|
|
|
|
);
|
|
|
|
if (file == null) return;
|
|
|
|
await _processReservationImageBytes(await file.readAsBytes());
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 订阅外部分享(如微信截图)进来的图片,自动进入预订识别流程。
|
|
|
|
void _initSharedReservationListener() {
|
|
|
|
final SharedReservationReceiver receiver =
|
|
|
|
SharedReservationReceiver.instance;
|
|
|
|
_sharedImageListener = () {
|
|
|
|
final String? path = receiver.pendingImage.value;
|
|
|
|
if (path == null) return;
|
|
|
|
unawaited(_processSharedReservationImage(path));
|
|
|
|
};
|
|
|
|
receiver.pendingImage.addListener(_sharedImageListener!);
|
|
|
|
|
|
|
|
final String? initialPath = receiver.pendingImage.value;
|
|
|
|
if (initialPath != null) {
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
unawaited(_processSharedReservationImage(initialPath));
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _processSharedReservationImage(String path) async {
|
|
|
|
if (_imageProcessing) return;
|
|
|
|
try {
|
|
|
|
final XFile? file = await ImagePicker().pickImage(
|
|
|
|
source: ImageSource.gallery,
|
|
|
|
maxWidth: 1600,
|
|
|
|
imageQuality: 80,
|
|
|
|
);
|
|
|
|
if (file == null) return;
|
|
|
|
|
|
|
|
final Uint8List bytes = await file.readAsBytes();
|
|
|
|
final BaiduOcrClient ocr = BaiduOcrClient(
|
|
|
|
apiKey: _baiduApiKey,
|
|
|
|
secretKey: _baiduSecretKey,
|
|
|
|
);
|
|
|
|
final String text = await ocr.recognize(bytes);
|
|
|
|
debugPrint('OCR识别成功: ${text.length}字');
|
|
|
|
final Uint8List bytes = await File(path).readAsBytes();
|
|
|
|
if (!mounted) return;
|
|
|
|
await _processReservationImageBytes(bytes);
|
|
|
|
} catch (error) {
|
|
|
|
debugPrint('读取分享图片失败: $error');
|
|
|
|
if (mounted) {
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
const SnackBar(content: Text('读取分享图片失败,请重试')),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} finally {
|
|
|
|
SharedReservationReceiver.instance.consumeImage();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _processReservationImageBytes(Uint8List bytes) async {
|
|
|
|
if (_imageProcessing) return;
|
|
|
|
setState(() => _imageProcessing = true);
|
|
|
|
try {
|
|
|
|
final QwenOcrClient ocr = QwenOcrClient(apiKey: _qwenApiKey);
|
|
|
|
final ReservationImageParseResult result =
|
|
|
|
ReservationImageParser.parse(text);
|
|
|
|
await ocr.parseReservation(bytes);
|
|
|
|
debugPrint('千问OCR识别成功: ${result.rawText.length}字');
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
if (!result.hasAny) {
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
const SnackBar(content: Text('没有从图片中识别到预订信息')),
|
|
...
|
...
|
@@ -1459,7 +1505,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
|
|
|
|
setState(() => _imageProcessing = false);
|
|
|
|
await _showReservationConfirm(_normalizeReservationDate(result));
|
|
|
|
} on BaiduOcrException catch (error) {
|
|
|
|
} on QwenOcrException catch (error) {
|
|
|
|
debugPrint('OCR失败: ${error.toString()}');
|
|
|
|
if (mounted) {
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
...
|
...
|
@@ -1511,10 +1557,11 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
if (timeText.isNotEmpty) timeText,
|
|
|
|
].join(' ');
|
|
|
|
|
|
|
|
final DateTime? bookingTime = _parseReservationTime(combined);
|
|
|
|
DateTime? bookingTime = _parseReservationTime(combined);
|
|
|
|
if (bookingTime == null || combined.isEmpty) {
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
bookingTime = _adjustFutureDate(bookingTime, result.rawText);
|
|
|
|
|
|
|
|
final String month = bookingTime.month.toString().padLeft(2, '0');
|
|
|
|
final String day = bookingTime.day.toString().padLeft(2, '0');
|
|
...
|
...
|
@@ -1563,6 +1610,67 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
NavigatorUtil.goNoteBookChangeFPage(context, detail, '');
|
|
|
|
}
|
|
|
|
|
|
|
|
DateTime _adjustFutureDate(DateTime date, String rawText) {
|
|
|
|
final DateTime now = DateTime.now();
|
|
|
|
final DateTime today = DateTime(now.year, now.month, now.day);
|
|
|
|
if (!DateTime(date.year, date.month, date.day).isBefore(today)) {
|
|
|
|
return date;
|
|
|
|
}
|
|
|
|
|
|
|
|
final int? weekday = _weekdayFromText(rawText);
|
|
|
|
final bool hasMonth =
|
|
|
|
RegExp(r'\d{1,2}\s*月|\d{4}[-/.]\d{1,2}').hasMatch(rawText);
|
|
|
|
|
|
|
|
DateTime? result;
|
|
|
|
if (weekday != null) {
|
|
|
|
for (int i = 1; i <= 3660; i++) {
|
|
|
|
final DateTime cursor = today.add(Duration(days: i));
|
|
|
|
if (cursor.day == date.day && cursor.weekday == weekday) {
|
|
|
|
result = cursor;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else if (hasMonth) {
|
|
|
|
DateTime candidate = DateTime(now.year, date.month, date.day);
|
|
|
|
if (candidate.isBefore(today)) {
|
|
|
|
candidate = DateTime(now.year + 1, date.month, date.day);
|
|
|
|
}
|
|
|
|
result = candidate;
|
|
|
|
} else {
|
|
|
|
DateTime candidate = DateTime(now.year, now.month, date.day);
|
|
|
|
if (candidate.isBefore(today)) {
|
|
|
|
candidate = DateTime(now.year, now.month + 1, date.day);
|
|
|
|
}
|
|
|
|
result = candidate;
|
|
|
|
}
|
|
|
|
|
|
|
|
final DateTime safe = result ?? date;
|
|
|
|
return DateTime(safe.year, safe.month, safe.day, date.hour, date.minute);
|
|
|
|
}
|
|
|
|
|
|
|
|
int? _weekdayFromText(String text) {
|
|
|
|
if (RegExp(r'周一').hasMatch(text)) return 1;
|
|
|
|
const Map<String, int> mapping = <String, int>{
|
|
|
|
'周二': 2,
|
|
|
|
'星期二': 2,
|
|
|
|
'周三': 3,
|
|
|
|
'星期三': 3,
|
|
|
|
'周四': 4,
|
|
|
|
'星期四': 4,
|
|
|
|
'周五': 5,
|
|
|
|
'星期五': 5,
|
|
|
|
'周六': 6,
|
|
|
|
'星期六': 6,
|
|
|
|
'周日': 7,
|
|
|
|
'星期日': 7,
|
|
|
|
'星期天': 7,
|
|
|
|
};
|
|
|
|
for (final MapEntry<String, int> entry in mapping.entries) {
|
|
|
|
if (text.contains(entry.key)) return entry.value;
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
String _sexCode(String sex) {
|
|
|
|
switch (sex) {
|
|
|
|
case '先生':
|
|
...
|
...
|
@@ -1593,8 +1701,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
int.parse(fullDate.group(3)!),
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
final Match? monthDay =
|
|
|
|
RegExp(r'(\d{1,2})月(\d{1,2})日').firstMatch(text);
|
|
|
|
final Match? monthDay = RegExp(r'(\d{1,2})月(\d{1,2})日').firstMatch(text);
|
|
|
|
if (monthDay != null) {
|
|
|
|
date = DateTime(
|
|
|
|
now.year,
|
|
...
|
...
|
@@ -1674,103 +1781,103 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
statusBarBrightness: Brightness.light,
|
|
|
|
),
|
|
|
|
child: Scaffold(
|
|
|
|
backgroundColor: const Color(0xFFF3F8F8),
|
|
|
|
appBar: AppBar(
|
|
|
|
backgroundColor: themeColor,
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
elevation: 0,
|
|
|
|
centerTitle: true,
|
|
|
|
title: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
const Text(
|
|
|
|
'AI 经营助手',
|
|
|
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
|
|
|
),
|
|
|
|
Text(
|
|
|
|
_storeName,
|
|
|
|
style: const TextStyle(
|
|
|
|
fontSize: 11,
|
|
|
|
color: Colors.white70,
|
|
|
|
backgroundColor: const Color(0xFFF3F8F8),
|
|
|
|
appBar: AppBar(
|
|
|
|
backgroundColor: themeColor,
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
elevation: 0,
|
|
|
|
centerTitle: true,
|
|
|
|
title: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
const Text(
|
|
|
|
'AI 经营助手',
|
|
|
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
Text(
|
|
|
|
_storeName,
|
|
|
|
style: const TextStyle(
|
|
|
|
fontSize: 11,
|
|
|
|
color: Colors.white70,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
body: Stack(
|
|
|
|
children: [
|
|
|
|
Column(
|
|
|
|
children: [
|
|
|
|
Expanded(
|
|
|
|
child: _historyLoading
|
|
|
|
? const Center(
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
color: MyColours.color_199095,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: ListView.builder(
|
|
|
|
controller: _scrollController,
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
horizontal: 12,
|
|
|
|
vertical: 14,
|
|
|
|
body: Stack(
|
|
|
|
children: [
|
|
|
|
Column(
|
|
|
|
children: [
|
|
|
|
Expanded(
|
|
|
|
child: _historyLoading
|
|
|
|
? const Center(
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
color: MyColours.color_199095,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: ListView.builder(
|
|
|
|
controller: _scrollController,
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
horizontal: 12,
|
|
|
|
vertical: 14,
|
|
|
|
),
|
|
|
|
itemCount: _messages.length,
|
|
|
|
itemBuilder: (_, int index) {
|
|
|
|
return _MessageBubble(
|
|
|
|
message: _messages[index],
|
|
|
|
themeColor: themeColor,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
itemCount: _messages.length,
|
|
|
|
itemBuilder: (_, int index) {
|
|
|
|
return _MessageBubble(
|
|
|
|
message: _messages[index],
|
|
|
|
themeColor: themeColor,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
_QuickPrompts(
|
|
|
|
controller: _promptScrollController,
|
|
|
|
themeColor: themeColor,
|
|
|
|
onSend: _send,
|
|
|
|
),
|
|
|
|
_InputBar(
|
|
|
|
controller: _inputController,
|
|
|
|
streaming: _streaming,
|
|
|
|
themeColor: themeColor,
|
|
|
|
onSend: _send,
|
|
|
|
onStop: _stop,
|
|
|
|
onVoiceLongPressStart: _startVoice,
|
|
|
|
onVoiceLongPressEnd: _stopVoice,
|
|
|
|
onVoiceTap: _showVoiceTapHint,
|
|
|
|
onPickImage: _pickReservationImage,
|
|
|
|
imageProcessing: _imageProcessing,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
if (_voiceStage == _VoiceStage.preparing)
|
|
|
|
_PreparingVoiceOverlay(
|
|
|
|
hint: _voiceHint,
|
|
|
|
progress: _whisperDownloadProgress,
|
|
|
|
themeColor: themeColor,
|
|
|
|
onCancel: _cancelVoicePreparation,
|
|
|
|
),
|
|
|
|
_QuickPrompts(
|
|
|
|
controller: _promptScrollController,
|
|
|
|
if (_voiceStage == _VoiceStage.recording)
|
|
|
|
_RecordOverlay(
|
|
|
|
seconds: _voiceSeconds,
|
|
|
|
themeColor: themeColor,
|
|
|
|
onSend: _send,
|
|
|
|
recognizedText: _recognizedText,
|
|
|
|
onStop: _stopVoice,
|
|
|
|
onCancel: _cancelVoiceRecording,
|
|
|
|
),
|
|
|
|
_InputBar(
|
|
|
|
controller: _inputController,
|
|
|
|
streaming: _streaming,
|
|
|
|
if (_voiceStage == _VoiceStage.confirming)
|
|
|
|
_ConfirmVoiceBox(
|
|
|
|
initialText: _confirmText,
|
|
|
|
themeColor: themeColor,
|
|
|
|
onSend: _send,
|
|
|
|
onStop: _stop,
|
|
|
|
onVoiceLongPressStart: _startVoice,
|
|
|
|
onVoiceLongPressEnd: _stopVoice,
|
|
|
|
onVoiceTap: _showVoiceTapHint,
|
|
|
|
onPickImage: _pickReservationImage,
|
|
|
|
imageProcessing: _imageProcessing,
|
|
|
|
onCancel: () {
|
|
|
|
setState(() => _voiceStage = _VoiceStage.idle);
|
|
|
|
},
|
|
|
|
onConfirm: _confirmVoice,
|
|
|
|
onChanged: (String value) => _confirmText = value,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
if (_voiceStage == _VoiceStage.preparing)
|
|
|
|
_PreparingVoiceOverlay(
|
|
|
|
hint: _voiceHint,
|
|
|
|
progress: _whisperDownloadProgress,
|
|
|
|
themeColor: themeColor,
|
|
|
|
onCancel: _cancelVoicePreparation,
|
|
|
|
),
|
|
|
|
if (_voiceStage == _VoiceStage.recording)
|
|
|
|
_RecordOverlay(
|
|
|
|
seconds: _voiceSeconds,
|
|
|
|
themeColor: themeColor,
|
|
|
|
recognizedText: _recognizedText,
|
|
|
|
onStop: _stopVoice,
|
|
|
|
onCancel: _cancelVoiceRecording,
|
|
|
|
),
|
|
|
|
if (_voiceStage == _VoiceStage.confirming)
|
|
|
|
_ConfirmVoiceBox(
|
|
|
|
initialText: _confirmText,
|
|
|
|
themeColor: themeColor,
|
|
|
|
onCancel: () {
|
|
|
|
setState(() => _voiceStage = _VoiceStage.idle);
|
|
|
|
},
|
|
|
|
onConfirm: _confirmVoice,
|
|
|
|
onChanged: (String value) => _confirmText = value,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -2087,7 +2194,8 @@ class _ReservationConfirmSheetState extends State<_ReservationConfirmSheet> { |
|
|
|
_field('预订人', _nameController),
|
|
|
|
_field('称谓', _sexController),
|
|
|
|
_field('联系电话', _phoneController, keyboardType: TextInputType.phone),
|
|
|
|
_field('用餐人数', _partyController, keyboardType: TextInputType.number),
|
|
|
|
_field('用餐人数', _partyController,
|
|
|
|
keyboardType: TextInputType.number),
|
|
|
|
_field('预订日期', _dateController),
|
|
|
|
_field('用餐时段', _mealController),
|
|
|
|
_field('预订时间', _timeController),
|
|
...
|
...
|
@@ -2640,7 +2748,8 @@ class _AgentCardDetailPage extends StatelessWidget { |
|
|
|
]);
|
|
|
|
return rows;
|
|
|
|
case _CardKind.tableList:
|
|
|
|
final List<_DetailRow> rows = card.items.map((Map<String, dynamic> item) {
|
|
|
|
final List<_DetailRow> rows =
|
|
|
|
card.items.map((Map<String, dynamic> item) {
|
|
|
|
final String rawName = '${item['name'] ?? ''}';
|
|
|
|
final String tableName = rawName.split(' · ').first.trim();
|
|
|
|
final String status = '${item['status'] ?? ''}';
|
...
|
...
|
|