|
|
|
import 'dart:async';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:dio/dio.dart';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
import 'package:mime/mime.dart';
|
|
|
|
import 'package:one_key/app.dart';
|
|
|
|
import 'package:one_key/models/notebook_detail.dart';
|
|
|
|
import 'package:one_key/models/user.dart';
|
|
|
|
import 'package:one_key/pages/agent/agent_api_client.dart';
|
|
|
|
import 'package:one_key/pages/agent/agent_chat_store.dart';
|
|
|
|
import 'package:one_key/pages/agent/qwen_ocr_client.dart';
|
|
|
|
import 'package:one_key/pages/agent/reservation_image_parser.dart';
|
|
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import 'package:one_key/pages/agent/shared_reservation_receiver.dart';
|
|
|
|
import 'package:one_key/pages/agent/paraformer_asr_client.dart';
|
|
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
import 'package:one_key/provider/user_model.dart';
|
|
|
|
import 'package:one_key/res/colors.dart';
|
|
|
|
import 'package:one_key/route/navigator_util.dart';
|
|
|
|
import 'package:one_key/utils/utils.dart';
|
|
|
|
import 'package:provider/provider.dart';
|
|
|
|
import 'package:record/record.dart';
|
|
|
|
import 'package:speech_to_text/speech_recognition_result.dart';
|
|
|
|
import 'package:speech_to_text/speech_to_text.dart';
|
|
|
|
import 'package:whisper_cpp_flutter_plus/whisper_cpp_flutter_plus.dart';
|
|
...
|
...
|
@@ -25,18 +32,30 @@ enum _Role { user, agent } |
|
|
|
|
|
|
|
enum _MessageStatus { sending, streaming, done, error }
|
|
|
|
|
|
|
|
enum _CardKind { kpi, tableList, bookingList, orderList, dishRank, plain }
|
|
|
|
enum _CardKind {
|
|
|
|
kpi,
|
|
|
|
tableList,
|
|
|
|
bookingList,
|
|
|
|
orderList,
|
|
|
|
dishRank,
|
|
|
|
plain,
|
|
|
|
reservationDraft
|
|
|
|
}
|
|
|
|
|
|
|
|
class _AgentCard {
|
|
|
|
const _AgentCard({
|
|
|
|
required this.kind,
|
|
|
|
required this.title,
|
|
|
|
required this.items,
|
|
|
|
this.followUps = const <String>[],
|
|
|
|
this.quickActions = const <String>[],
|
|
|
|
});
|
|
|
|
|
|
|
|
final _CardKind kind;
|
|
|
|
final String title;
|
|
|
|
final List<Map<String, dynamic>> items;
|
|
|
|
final List<String> followUps;
|
|
|
|
final List<String> quickActions;
|
|
|
|
}
|
|
|
|
|
|
|
|
class _ChatMessage {
|
|
...
|
...
|
@@ -45,6 +64,9 @@ class _ChatMessage { |
|
|
|
required this.text,
|
|
|
|
this.card,
|
|
|
|
this.status = _MessageStatus.done,
|
|
|
|
this.imagePath,
|
|
|
|
this.clientMessageId,
|
|
|
|
this.inputType = 'text',
|
|
|
|
String? id,
|
|
|
|
DateTime? ts,
|
|
|
|
}) : id = id ?? '${DateTime.now().microsecondsSinceEpoch}_${role.name}',
|
|
...
|
...
|
@@ -56,6 +78,9 @@ class _ChatMessage { |
|
|
|
_AgentCard? card;
|
|
|
|
_MessageStatus status;
|
|
|
|
DateTime ts;
|
|
|
|
final String? imagePath;
|
|
|
|
final String? clientMessageId;
|
|
|
|
final String inputType;
|
|
|
|
}
|
|
|
|
|
|
|
|
class _MockReply {
|
|
...
|
...
|
@@ -88,6 +113,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
final List<_ChatMessage> _messages = [];
|
|
|
|
final SpeechToText _speech = SpeechToText();
|
|
|
|
final AgentChatStore _chatStore = AgentChatStore.instance;
|
|
|
|
final AgentApiClient _agentApi = AgentApiClient.instance;
|
|
|
|
|
|
|
|
Timer? _typingTimer;
|
|
|
|
Timer? _voiceTimer;
|
|
...
|
...
|
@@ -97,6 +123,472 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
bool _historyLoading = true;
|
|
|
|
bool _usingNativeSpeech = false;
|
|
|
|
bool _usingWhisper = false;
|
|
|
|
bool _nextMessageIsVoice = false;
|
|
|
|
bool _agentAuthExpiredHandling = false;
|
|
|
|
String? _agentConversationId;
|
|
|
|
CancelToken? _agentCancelToken;
|
|
|
|
AudioRecorder? _agentAudioRecorder;
|
|
|
|
String? _agentAudioPath;
|
|
|
|
|
|
|
|
/// ---- 预订会话式录入 ----
|
|
|
|
ReservationImageParseResult? _pendingReservation;
|
|
|
|
bool _reservationAwaitingConfirm = false;
|
|
|
|
|
|
|
|
bool _reservationRequiredComplete(ReservationImageParseResult result) {
|
|
|
|
return result.name.isNotEmpty &&
|
|
|
|
result.phone.isNotEmpty &&
|
|
|
|
result.party.isNotEmpty &&
|
|
|
|
result.date.isNotEmpty;
|
|
|
|
}
|
|
|
|
|
|
|
|
ReservationImageParseResult _mergeReservation(
|
|
|
|
ReservationImageParseResult old, ReservationImageParseResult next) {
|
|
|
|
return ReservationImageParseResult(
|
|
|
|
name: next.name.isNotEmpty ? next.name : old.name,
|
|
|
|
sex: next.sex.isNotEmpty ? next.sex : old.sex,
|
|
|
|
phone: next.phone.isNotEmpty ? next.phone : old.phone,
|
|
|
|
party: next.party.isNotEmpty ? next.party : old.party,
|
|
|
|
date: next.date.isNotEmpty ? next.date : old.date,
|
|
|
|
meal: next.meal.isNotEmpty ? next.meal : old.meal,
|
|
|
|
time: next.time.isNotEmpty ? next.time : old.time,
|
|
|
|
table: next.table.isNotEmpty ? next.table : old.table,
|
|
|
|
remark: next.remark.isNotEmpty ? next.remark : old.remark,
|
|
|
|
rawText: next.rawText.isNotEmpty ? next.rawText : old.rawText,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
String _reservationIntro(ReservationImageParseResult result) {
|
|
|
|
final List<String> parts = <String>[];
|
|
|
|
if (result.name.isNotEmpty) parts.add('👤 预订人:${result.name}');
|
|
|
|
if (result.phone.isNotEmpty) parts.add('📱 电话:${result.phone}');
|
|
|
|
if (result.party.isNotEmpty) parts.add('👥 人数:${result.party}人');
|
|
|
|
if (result.date.isNotEmpty) parts.add('📅 日期:${result.date}');
|
|
|
|
if (result.meal.isNotEmpty) parts.add('🍽 时段:${result.meal}');
|
|
|
|
if (result.time.isNotEmpty) parts.add('⏰ 到店时间:${result.time}');
|
|
|
|
if (result.table.isNotEmpty) parts.add('🪑 桌台:${result.table}');
|
|
|
|
if (parts.isEmpty) {
|
|
|
|
return '我没有从信息中识别到完整的预订字段,咱们一起补全吧。';
|
|
|
|
}
|
|
|
|
return '📋 识别到以下信息:\n${parts.join('\n')}';
|
|
|
|
}
|
|
|
|
|
|
|
|
String _reservationSummaryText(ReservationImageParseResult result) {
|
|
|
|
final List<String> parts = <String>[];
|
|
|
|
if (result.name.isNotEmpty) parts.add('姓名: ${result.name}');
|
|
|
|
if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}');
|
|
|
|
if (result.party.isNotEmpty) parts.add('人数: ${result.party}人');
|
|
|
|
if (result.date.isNotEmpty) parts.add('日期: ${result.date}');
|
|
|
|
if (result.meal.isNotEmpty) parts.add('时段: ${result.meal}');
|
|
|
|
if (result.time.isNotEmpty) parts.add('到店时间: ${result.time}');
|
|
|
|
if (result.table.isNotEmpty) parts.add('桌台: ${result.table}');
|
|
|
|
return parts.join('\n');
|
|
|
|
}
|
|
|
|
|
|
|
|
String _nextReservationQuestion(ReservationImageParseResult result) {
|
|
|
|
final List<String> missing = <String>[];
|
|
|
|
if (result.name.isEmpty) missing.add('预订人姓名');
|
|
|
|
if (result.phone.isEmpty) missing.add('手机号');
|
|
|
|
if (result.party.isEmpty) missing.add('用餐人数');
|
|
|
|
if (result.date.isEmpty) missing.add('预订日期');
|
|
|
|
if (missing.isEmpty) return '信息已经齐全啦,确认一下就可以录入。';
|
|
|
|
return '还缺 ${missing.join('、')},直接回复补充即可,\n例如"王先生 13800138000 8人 明晚"。';
|
|
|
|
}
|
|
|
|
|
|
|
|
_AgentCard _reservationDraftCard(ReservationImageParseResult result,
|
|
|
|
{List<String> quickActions = const <String>[]}) {
|
|
|
|
final List<String> actions = quickActions.isNotEmpty
|
|
|
|
? quickActions
|
|
|
|
: const <String>['确认录入', '好了,没问题', '修改信息', '取消'];
|
|
|
|
// 字段不全时不带快捷按钮,但带追问词条引导补全
|
|
|
|
final bool complete = _reservationRequiredComplete(result);
|
|
|
|
return _AgentCard(
|
|
|
|
kind: _CardKind.reservationDraft,
|
|
|
|
title: '预订信息草稿',
|
|
|
|
followUps: complete ? const <String>[] : const <String>['我直接说', '重新识别'],
|
|
|
|
items: <Map<String, dynamic>>[
|
|
|
|
{'label': '姓名', 'value': result.name.isEmpty ? '待补充' : result.name},
|
|
|
|
{'label': '电话', 'value': result.phone.isEmpty ? '待补充' : result.phone},
|
|
|
|
{
|
|
|
|
'label': '人数',
|
|
|
|
'value': result.party.isEmpty ? '待补充' : '${result.party}人'
|
|
|
|
},
|
|
|
|
{'label': '日期', 'value': result.date.isEmpty ? '待补充' : result.date},
|
|
|
|
if (result.meal.isNotEmpty) {'label': '时段', 'value': result.meal},
|
|
|
|
if (result.time.isNotEmpty) {'label': '到店时间', 'value': result.time},
|
|
|
|
if (result.table.isNotEmpty) {'label': '桌台', 'value': result.table},
|
|
|
|
],
|
|
|
|
quickActions: _reservationAwaitingConfirm ? actions : const <String>[],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _isReservationConfirmText(String text) {
|
|
|
|
final String t = text.trim();
|
|
|
|
// 精确匹配所有快捷按钮文字 + 常见口语确认
|
|
|
|
if (RegExp(
|
|
|
|
r'^(确认|确认录入|好了|好了,没问题|好的|可以|可以录入|没问题|没问题了|没问题,录入吧|录入|行|对|ok|yes)$',
|
|
|
|
caseSensitive: false)
|
|
|
|
.hasMatch(t)) return true;
|
|
|
|
if (t.contains('确认') &&
|
|
|
|
!t.contains('改') &&
|
|
|
|
!t.contains('换') &&
|
|
|
|
!t.contains('不是') &&
|
|
|
|
!t.contains('错了')) return true;
|
|
|
|
if ((t.contains('好了') || t.contains('没问题')) &&
|
|
|
|
!t.contains('改') &&
|
|
|
|
!t.contains('不是')) return true;
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _isModificationIntent(String text) {
|
|
|
|
final String t = text.trim();
|
|
|
|
return t == '修改信息' ||
|
|
|
|
t == '修改' ||
|
|
|
|
t == '改一下' ||
|
|
|
|
t == '改' ||
|
|
|
|
t == '我要修改' ||
|
|
|
|
t == '需要修改' ||
|
|
|
|
t == '修改一下' ||
|
|
|
|
t == '改改' ||
|
|
|
|
t == '要改' ||
|
|
|
|
t == '调整一下' ||
|
|
|
|
t == '调整';
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 从自然语言修改指令中提取字段(如"把手机号改成138xxxx")。
|
|
|
|
ReservationImageParseResult _parseModificationText(String text) {
|
|
|
|
String name = '';
|
|
|
|
String phone = '';
|
|
|
|
String party = '';
|
|
|
|
String date = '';
|
|
|
|
String meal = '';
|
|
|
|
String time = '';
|
|
|
|
String table = '';
|
|
|
|
|
|
|
|
// 模式:把/将 XXX 改成/改为/换成 YYY
|
|
|
|
final RegExp modPattern = RegExp(
|
|
|
|
r'(?:把|将)?\s*(\S+?)\s*(?:改成|改为|换成|改成是|换为|修改为|改成是)\s*(\S+)',
|
|
|
|
);
|
|
|
|
for (final Match m in modPattern.allMatches(text)) {
|
|
|
|
final String field = m.group(1) ?? '';
|
|
|
|
final String value = m.group(2) ?? '';
|
|
|
|
|
|
|
|
if (field.contains('名') || field.contains('姓') || field == '名字') {
|
|
|
|
name = value;
|
|
|
|
} else if (field.contains('手机') ||
|
|
|
|
field.contains('电话') ||
|
|
|
|
field.contains('号')) {
|
|
|
|
phone = RegExp(r'1[3-9]\d{9}').firstMatch(value)?.group(0) ?? value;
|
|
|
|
} else if (field.contains('人') || field.contains('位')) {
|
|
|
|
party = RegExp(r'\d+').firstMatch(value)?.group(0) ?? '';
|
|
|
|
} else if (field.contains('日') || field.contains('期')) {
|
|
|
|
date = value;
|
|
|
|
} else if (field.contains('时') ||
|
|
|
|
field.contains('餐') ||
|
|
|
|
field.contains('饭')) {
|
|
|
|
meal = value;
|
|
|
|
} else if (field.contains('桌') ||
|
|
|
|
field.contains('台') ||
|
|
|
|
field.contains('包间')) {
|
|
|
|
table = value;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 回退:直接尝试标准解析
|
|
|
|
if (name.isEmpty && phone.isEmpty && party.isEmpty && date.isEmpty) {
|
|
|
|
return ReservationImageParser.parse(text);
|
|
|
|
}
|
|
|
|
|
|
|
|
return ReservationImageParseResult(
|
|
|
|
name: name,
|
|
|
|
sex: '',
|
|
|
|
phone: phone,
|
|
|
|
party: party,
|
|
|
|
date: date,
|
|
|
|
meal: meal,
|
|
|
|
time: time,
|
|
|
|
table: table,
|
|
|
|
remark: '',
|
|
|
|
rawText: text,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
String _describeChanges(
|
|
|
|
ReservationImageParseResult before, ReservationImageParseResult after) {
|
|
|
|
final List<String> fields = <String>[];
|
|
|
|
if (after.name.isNotEmpty && after.name != before.name) fields.add('姓名');
|
|
|
|
if (after.phone.isNotEmpty && after.phone != before.phone)
|
|
|
|
fields.add('手机号');
|
|
|
|
if (after.party.isNotEmpty && after.party != before.party) fields.add('人数');
|
|
|
|
if (after.date.isNotEmpty && after.date != before.date) fields.add('日期');
|
|
|
|
if (after.meal.isNotEmpty && after.meal != before.meal) fields.add('时段');
|
|
|
|
if (after.time.isNotEmpty && after.time != before.time) fields.add('到店时间');
|
|
|
|
if (after.table.isNotEmpty && after.table != before.table) fields.add('桌台');
|
|
|
|
if (fields.isEmpty) return '';
|
|
|
|
return fields.join('、');
|
|
|
|
}
|
|
|
|
|
|
|
|
Map<String, String> _reservationToFields(ReservationImageParseResult result) {
|
|
|
|
return <String, String>{
|
|
|
|
'name': result.name,
|
|
|
|
'sex': result.sex,
|
|
|
|
'phone': result.phone,
|
|
|
|
'party': result.party,
|
|
|
|
'date': result.date,
|
|
|
|
'meal': result.meal,
|
|
|
|
'time': result.time,
|
|
|
|
'table': result.table,
|
|
|
|
'remark': result.remark,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 订单确认后的摘要文本(管家风格)。
|
|
|
|
String _reservationOrderSummary(
|
|
|
|
ReservationImageParseResult result, Map<String, String> fields) {
|
|
|
|
final StringBuffer sb = StringBuffer();
|
|
|
|
final String name = fields['name']?.trim() ?? '';
|
|
|
|
final String sex = fields['sex']?.trim() ?? '';
|
|
|
|
final String phone = fields['phone']?.trim() ?? '';
|
|
|
|
final String party = fields['party']?.trim() ?? '';
|
|
|
|
final String date = fields['date']?.trim() ?? '';
|
|
|
|
final String meal = fields['meal']?.trim() ?? '';
|
|
|
|
final String time = fields['time']?.trim() ?? '';
|
|
|
|
final String table = fields['table']?.trim() ?? '';
|
|
|
|
|
|
|
|
// 第一行:核心信息一行展示
|
|
|
|
final List<String> meta = <String>[];
|
|
|
|
if (date.isNotEmpty) meta.add(date);
|
|
|
|
if (meal.isNotEmpty) meta.add(meal);
|
|
|
|
if (table.isNotEmpty) meta.add('$table桌');
|
|
|
|
sb.writeln(meta.isNotEmpty ? '📌 ${meta.join(' · ')}' : '📌 已确认');
|
|
|
|
sb.writeln('━━━━━━━━━━━━━━');
|
|
|
|
|
|
|
|
// 第二行:人物信息
|
|
|
|
final String displayName = sex.isNotEmpty ? '$name$sex' : name;
|
|
|
|
sb.write('👤 $displayName');
|
|
|
|
if (party.isNotEmpty) sb.write(' · $party人');
|
|
|
|
if (phone.isNotEmpty) sb.write(' · $phone');
|
|
|
|
sb.writeln();
|
|
|
|
|
|
|
|
// 附加信息
|
|
|
|
if (time.isNotEmpty) {
|
|
|
|
sb.writeln('⏰ 到店时间:$time');
|
|
|
|
}
|
|
|
|
final String remark = fields['remark']?.trim() ?? '';
|
|
|
|
if (remark.isNotEmpty) {
|
|
|
|
sb.writeln('📝 备注:$remark');
|
|
|
|
}
|
|
|
|
|
|
|
|
return sb.toString().trim();
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 确认后的订单卡片(管家风格,带追问)。
|
|
|
|
_AgentCard _reservationOrderCard(
|
|
|
|
ReservationImageParseResult result, Map<String, String> fields) {
|
|
|
|
final String date = fields['date']?.trim() ?? '';
|
|
|
|
final String meal = fields['meal']?.trim() ?? '';
|
|
|
|
final String title = date.isNotEmpty
|
|
|
|
? (meal.isNotEmpty ? '预订确认 · $date $meal' : '预订确认 · $date')
|
|
|
|
: '预订确认 ✅';
|
|
|
|
return _AgentCard(
|
|
|
|
kind: _CardKind.plain,
|
|
|
|
title: title,
|
|
|
|
items: <Map<String, dynamic>>[
|
|
|
|
{
|
|
|
|
'label': '预订人',
|
|
|
|
'value':
|
|
|
|
'${fields['name']?.trim() ?? "-"}${(fields['sex']?.trim() ?? "").isNotEmpty ? "(${fields['sex']!.trim()})" : ""}',
|
|
|
|
},
|
|
|
|
{'label': '电话', 'value': fields['phone']?.trim() ?? "-"},
|
|
|
|
{'label': '人数', 'value': '${fields['party']?.trim() ?? "-"}人'},
|
|
|
|
if ((fields['time']?.trim() ?? '').isNotEmpty)
|
|
|
|
{'label': '到店时间', 'value': fields['time']!.trim()},
|
|
|
|
if ((fields['table']?.trim() ?? '').isNotEmpty)
|
|
|
|
{'label': '桌台', 'value': '${fields['table']!.trim()}桌'},
|
|
|
|
if ((fields['remark']?.trim() ?? '').isNotEmpty)
|
|
|
|
{'label': '备注', 'value': fields['remark']!.trim()},
|
|
|
|
],
|
|
|
|
followUps: const <String>['今晚还有空桌吗?', '查看今日预订', '查订单'],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _startReservationConversation(ReservationImageParseResult result,
|
|
|
|
{String? imagePath}) async {
|
|
|
|
if (!mounted) return;
|
|
|
|
_pendingReservation = _normalizeReservationDate(result);
|
|
|
|
_reservationAwaitingConfirm =
|
|
|
|
_reservationRequiredComplete(_pendingReservation!);
|
|
|
|
final String intro = _reservationIntro(_pendingReservation!);
|
|
|
|
final String replyText = _reservationAwaitingConfirm
|
|
|
|
? '$intro\n\n信息齐全,确认无误即可录入。'
|
|
|
|
: '$intro\n\n⚠️ ${_nextReservationQuestion(_pendingReservation!)}';
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.user,
|
|
|
|
text: imagePath != null && imagePath.isNotEmpty
|
|
|
|
? '我发来一张预订截图'
|
|
|
|
: '语音录入预订信息',
|
|
|
|
imagePath: imagePath,
|
|
|
|
));
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: replyText,
|
|
|
|
card: _reservationDraftCard(_pendingReservation!),
|
|
|
|
));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _handleReservationClarification(String text) async {
|
|
|
|
final ReservationImageParseResult? current = _pendingReservation;
|
|
|
|
if (current == null || !mounted) return;
|
|
|
|
if (text.contains('取消') || text.contains('算了') || text.contains('不用')) {
|
|
|
|
_pendingReservation = null;
|
|
|
|
_reservationAwaitingConfirm = false;
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(role: _Role.user, text: text));
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: '好的,已取消这次预订录入。还想查什么随时问我~',
|
|
|
|
card: _AgentCard(
|
|
|
|
kind: _CardKind.plain,
|
|
|
|
title: '已取消录入',
|
|
|
|
items: const <Map<String, dynamic>>[],
|
|
|
|
followUps: const <String>['今晚还有空桌吗?', '查看今日预订', '重新录入预订'],
|
|
|
|
),
|
|
|
|
));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(role: _Role.user, text: text));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
|
|
|
|
if (_reservationAwaitingConfirm) {
|
|
|
|
if (_isReservationConfirmText(text)) {
|
|
|
|
final Map<String, String> fields = _reservationToFields(current);
|
|
|
|
final String summary = _reservationOrderSummary(current, fields);
|
|
|
|
_pendingReservation = null;
|
|
|
|
_reservationAwaitingConfirm = false;
|
|
|
|
|
|
|
|
// 根据多轮补全次数调整语气
|
|
|
|
final int totalRounds = _messages
|
|
|
|
.where((m) => m.role == _Role.user && m.text.contains('预订'))
|
|
|
|
.length;
|
|
|
|
final String tone =
|
|
|
|
totalRounds <= 2 ? '搞定!预订已录入 ✅' : '不容易,终于凑齐了!已帮你录入 ✅';
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: '$tone\n\n$summary\n━━━━━━━━━━━━━━\n'
|
|
|
|
'还需要我帮你看看什么?',
|
|
|
|
card: _reservationOrderCard(current, fields),
|
|
|
|
));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (_isModificationIntent(text)) {
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: '好的,请问要修改哪一项?直接告诉我就行,比如"手机号改成138xxxx""姓名改成张三"。',
|
|
|
|
card: _reservationDraftCard(current)));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// 优先用标准解析器,解析失败时尝试自然语言"改成"模式
|
|
|
|
ReservationImageParseResult parsed = ReservationImageParser.parse(text);
|
|
|
|
if (!parsed.hasAny) {
|
|
|
|
parsed = _parseModificationText(text);
|
|
|
|
}
|
|
|
|
final bool hasUpdate = parsed.hasAny;
|
|
|
|
final ReservationImageParseResult merged =
|
|
|
|
_normalizeReservationDate(_mergeReservation(current, parsed));
|
|
|
|
_pendingReservation = merged;
|
|
|
|
_reservationAwaitingConfirm = _reservationRequiredComplete(merged);
|
|
|
|
final String changed = hasUpdate ? _describeChanges(current, parsed) : '';
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: hasUpdate
|
|
|
|
? (_reservationAwaitingConfirm
|
|
|
|
? '已修改$changed,确认无误回复"确认"即可录入。'
|
|
|
|
: '已修改$changed,${_nextReservationQuestion(merged)}')
|
|
|
|
: '没有识别到具体要修改的内容,能再说一遍吗?\n比如"手机号改成138xxxx0000""姓名改成张三"。',
|
|
|
|
card: _reservationDraftCard(merged)));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
final ReservationImageParseResult parsed =
|
|
|
|
ReservationImageParser.parse(text);
|
|
|
|
final ReservationImageParseResult merged =
|
|
|
|
_normalizeReservationDate(_mergeReservation(current, parsed));
|
|
|
|
if (_reservationRequiredComplete(merged)) {
|
|
|
|
_pendingReservation = merged;
|
|
|
|
_reservationAwaitingConfirm = true;
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text:
|
|
|
|
'信息已经齐全:\n${_reservationSummaryText(merged)}\n\n确认无误就回复"确认",我直接帮你录入;要修改哪一项也直接告诉我。',
|
|
|
|
card: _reservationDraftCard(merged)));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
_pendingReservation = merged;
|
|
|
|
_reservationAwaitingConfirm = false;
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: _nextReservationQuestion(merged),
|
|
|
|
card: _reservationDraftCard(merged)));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
}
|
|
|
|
|
|
|
|
List<String> _followUpsFor(_CardKind kind) {
|
|
|
|
switch (kind) {
|
|
|
|
case _CardKind.kpi:
|
|
|
|
return const <String>['昨天呢?', '近7天统计', '同比环比'];
|
|
|
|
case _CardKind.tableList:
|
|
|
|
return const <String>['今晚空桌呢?', '4人可用桌位', '桌位状态'];
|
|
|
|
case _CardKind.bookingList:
|
|
|
|
return const <String>['未确认的预订', '预订详情'];
|
|
|
|
case _CardKind.orderList:
|
|
|
|
return const <String>['支付状态', '指定桌位订单'];
|
|
|
|
case _CardKind.dishRank:
|
|
|
|
return const <String>['销量排行', '销售额排行', '滞销菜品'];
|
|
|
|
case _CardKind.reservationDraft:
|
|
|
|
case _CardKind.plain:
|
|
|
|
return const <String>[];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_AgentCard _withFollowUps(_AgentCard card) {
|
|
|
|
return _AgentCard(
|
|
|
|
kind: card.kind,
|
|
|
|
title: card.title,
|
|
|
|
items: card.items,
|
|
|
|
followUps: _followUpsFor(card.kind),
|
|
|
|
quickActions: card.quickActions,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _usingCloudAsr = false;
|
|
|
|
bool _whisperModelReady = false;
|
|
|
|
bool _voicePressActive = false;
|
|
...
|
...
|
@@ -132,16 +624,99 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
return model.user?.sellerid ?? 'unknown';
|
|
|
|
}
|
|
|
|
|
|
|
|
AgentCredentials? _agentCredentials() {
|
|
|
|
final User? user = Provider.of<UserModel>(context, listen: false).user;
|
|
|
|
if (user == null) return null;
|
|
|
|
final int? uid = int.tryParse(user.openid ?? '');
|
|
|
|
final int? sellerId = int.tryParse(user.sellerid ?? '');
|
|
|
|
final String loginToken = user.token ?? '';
|
|
|
|
if (uid == null || sellerId == null || loginToken.isEmpty) return null;
|
|
|
|
|
|
|
|
final String deviceName = switch (defaultTargetPlatform) {
|
|
|
|
TargetPlatform.android => 'android-main',
|
|
|
|
TargetPlatform.iOS => 'ios-main',
|
|
|
|
_ => 'desktop-main',
|
|
|
|
};
|
|
|
|
return AgentCredentials(
|
|
|
|
uid: uid,
|
|
|
|
sellerId: sellerId,
|
|
|
|
loginToken: loginToken,
|
|
|
|
deviceName: deviceName,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _prepareAgentSession() async {
|
|
|
|
try {
|
|
|
|
final AgentCredentials? credentials = _agentCredentials();
|
|
|
|
if (credentials == null) return;
|
|
|
|
await _agentApi.prepare(credentials);
|
|
|
|
await _ensureAgentConversation();
|
|
|
|
} on AgentApiException catch (error) {
|
|
|
|
if (error.statusCode == 401) {
|
|
|
|
_handleAgentAuthExpired();
|
|
|
|
} else {
|
|
|
|
debugPrint('Agent session prepare skipped: $error');
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
debugPrint('Agent session prepare skipped: $error');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleAgentAuthExpired() {
|
|
|
|
if (!mounted || _agentAuthExpiredHandling) return;
|
|
|
|
_agentAuthExpiredHandling = true;
|
|
|
|
_agentConversationId = null;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
const SnackBar(content: Text('App 登录已过期,请重新登录')),
|
|
|
|
);
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
if (mounted) NavigatorUtil.goLoginPage(context);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<String> _ensureAgentConversation({CancelToken? cancelToken}) async {
|
|
|
|
final String? current = _agentConversationId;
|
|
|
|
if (current != null && current.isNotEmpty) return current;
|
|
|
|
final AgentConversation conversation =
|
|
|
|
await _agentApi.createConversation(cancelToken: cancelToken);
|
|
|
|
_agentConversationId = conversation.id;
|
|
|
|
return conversation.id;
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _disposeAgentAudioRecorder(
|
|
|
|
AudioRecorder? recorder, {
|
|
|
|
bool cancelFirst = false,
|
|
|
|
}) async {
|
|
|
|
if (recorder == null) return;
|
|
|
|
if (cancelFirst) {
|
|
|
|
try {
|
|
|
|
await recorder.cancel();
|
|
|
|
} catch (_) {}
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
await recorder.dispose();
|
|
|
|
} catch (_) {}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _deleteAgentVoiceFile(String? path) {
|
|
|
|
if (path == null || path.isEmpty) return;
|
|
|
|
final File file = File(path);
|
|
|
|
if (file.existsSync()) {
|
|
|
|
try {
|
|
|
|
file.deleteSync();
|
|
|
|
} catch (_) {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
Utils.changeStatusColor(color: MyColours.color_199095);
|
|
|
|
if (!_supportsWhisper) {
|
|
|
|
_initSpeech();
|
|
|
|
} else {
|
|
|
|
unawaited(_prewarmWhisperEngine());
|
|
|
|
}
|
|
|
|
_loadHistory();
|
|
|
|
unawaited(_prepareAgentSession());
|
|
|
|
_initSharedReservationListener();
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -159,6 +734,14 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
}
|
|
|
|
unawaited(_whisperTask?.cancel());
|
|
|
|
unawaited(_cloudAsrSession?.cancel());
|
|
|
|
_agentCancelToken?.cancel();
|
|
|
|
_deleteAgentVoiceFile(_agentAudioPath);
|
|
|
|
unawaited(_disposeAgentAudioRecorder(
|
|
|
|
_agentAudioRecorder,
|
|
|
|
cancelFirst: true,
|
|
|
|
));
|
|
|
|
_agentAudioRecorder = null;
|
|
|
|
_agentAudioPath = null;
|
|
|
|
try {
|
|
|
|
_whisperEngine?.dispose();
|
|
|
|
} catch (_) {}
|
|
...
|
...
|
@@ -258,6 +841,12 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
title: json['title']?.toString() ?? '',
|
|
|
|
items: (json['items'] as List? ?? const <dynamic>[])
|
|
|
|
.cast<Map<String, dynamic>>(),
|
|
|
|
followUps: (json['followUps'] as List? ?? const <dynamic>[])
|
|
|
|
.whereType<String>()
|
|
|
|
.toList(),
|
|
|
|
quickActions: (json['quickActions'] as List? ?? const <dynamic>[])
|
|
|
|
.whereType<String>()
|
|
|
|
.toList(),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -273,6 +862,8 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
return _CardKind.orderList;
|
|
|
|
case 'dish_rank':
|
|
|
|
return _CardKind.dishRank;
|
|
|
|
case 'reservation_draft':
|
|
|
|
return _CardKind.reservationDraft;
|
|
|
|
default:
|
|
|
|
return _CardKind.plain;
|
|
|
|
}
|
|
...
|
...
|
@@ -290,6 +881,8 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
return 'order_list';
|
|
|
|
case _CardKind.dishRank:
|
|
|
|
return 'dish_rank';
|
|
|
|
case _CardKind.reservationDraft:
|
|
|
|
return 'reservation_draft';
|
|
|
|
case _CardKind.plain:
|
|
|
|
return 'plain';
|
|
|
|
}
|
|
...
|
...
|
@@ -336,25 +929,48 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
void _send(String rawText) {
|
|
|
|
void _send(String rawText, {String inputType = 'text'}) {
|
|
|
|
final String text = rawText.trim();
|
|
|
|
if (text.isEmpty || _streaming) return;
|
|
|
|
|
|
|
|
if (_pendingReservation != null) {
|
|
|
|
_handleReservationClarification(text);
|
|
|
|
_inputController.clear();
|
|
|
|
FocusScope.of(context).unfocus();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (_maybeEnterReservationFlow(text)) {
|
|
|
|
_inputController.clear();
|
|
|
|
FocusScope.of(context).unfocus();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
_sendAgentMessage(text, inputType: inputType);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _sendAgentMessage(
|
|
|
|
String text, {
|
|
|
|
String inputType = 'text',
|
|
|
|
String? clientMessageId,
|
|
|
|
}) {
|
|
|
|
final String messageId = clientMessageId ?? _newClientMessageId();
|
|
|
|
final _ChatMessage userMessage = _ChatMessage(
|
|
|
|
role: _Role.user,
|
|
|
|
text: text,
|
|
|
|
clientMessageId: messageId,
|
|
|
|
inputType: inputType,
|
|
|
|
);
|
|
|
|
final _ChatMessage agentMessage = _ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: '',
|
|
|
|
status: _MessageStatus.sending,
|
|
|
|
);
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(role: _Role.user, text: text));
|
|
|
|
_messages.add(
|
|
|
|
_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: '',
|
|
|
|
status: _MessageStatus.sending,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
_messages
|
|
|
|
..add(userMessage)
|
|
|
|
..add(agentMessage);
|
|
|
|
_streaming = true;
|
|
|
|
_stopRequested = false;
|
|
|
|
_voiceStage = _VoiceStage.idle;
|
|
...
|
...
|
@@ -365,11 +981,148 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
|
|
|
|
final _MockReply reply = _mockReply(text);
|
|
|
|
Future.delayed(const Duration(milliseconds: 450), () {
|
|
|
|
if (!mounted) return;
|
|
|
|
_startTypewriter(reply);
|
|
|
|
unawaited(_requestAgentReply(
|
|
|
|
text: text,
|
|
|
|
inputType: inputType,
|
|
|
|
clientMessageId: messageId,
|
|
|
|
agentMessage: agentMessage,
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
|
|
|
String _newClientMessageId() {
|
|
|
|
return 'app-msg-${DateTime.now().microsecondsSinceEpoch}-${_messages.length}';
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _requestAgentReply({
|
|
|
|
required String text,
|
|
|
|
required String inputType,
|
|
|
|
required String clientMessageId,
|
|
|
|
required _ChatMessage agentMessage,
|
|
|
|
}) async {
|
|
|
|
final CancelToken cancelToken = CancelToken();
|
|
|
|
_agentCancelToken = cancelToken;
|
|
|
|
|
|
|
|
try {
|
|
|
|
final AgentCredentials? credentials = _agentCredentials();
|
|
|
|
if (credentials == null) {
|
|
|
|
throw const AgentApiException(0, '未获取到登录信息,请重新登录');
|
|
|
|
}
|
|
|
|
await _agentApi.prepare(credentials);
|
|
|
|
|
|
|
|
String conversationId = await _ensureAgentConversation(
|
|
|
|
cancelToken: cancelToken,
|
|
|
|
);
|
|
|
|
AgentMessageData data;
|
|
|
|
try {
|
|
|
|
data = await _agentApi.sendMessage(
|
|
|
|
conversationId: conversationId,
|
|
|
|
content: text,
|
|
|
|
inputType: inputType,
|
|
|
|
clientMessageId: clientMessageId,
|
|
|
|
cancelToken: cancelToken,
|
|
|
|
);
|
|
|
|
} on AgentApiException catch (error) {
|
|
|
|
if (error.statusCode != 404) rethrow;
|
|
|
|
_agentConversationId = null;
|
|
|
|
conversationId = await _ensureAgentConversation(
|
|
|
|
cancelToken: cancelToken,
|
|
|
|
);
|
|
|
|
data = await _agentApi.sendMessage(
|
|
|
|
conversationId: conversationId,
|
|
|
|
content: text,
|
|
|
|
inputType: inputType,
|
|
|
|
clientMessageId: clientMessageId,
|
|
|
|
cancelToken: cancelToken,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
final String reply = data.content.trim();
|
|
|
|
if (reply.isEmpty) {
|
|
|
|
throw const AgentApiException(0, 'AI 没有返回内容,请重试');
|
|
|
|
}
|
|
|
|
if (!mounted || cancelToken.isCancelled) return;
|
|
|
|
_startTypewriter(_MockReply(reply));
|
|
|
|
} on AgentApiException catch (error) {
|
|
|
|
if (error.statusCode == 401) {
|
|
|
|
_handleAgentAuthExpired();
|
|
|
|
}
|
|
|
|
_finishAgentRequestError(agentMessage, error.message);
|
|
|
|
} on DioException catch (error) {
|
|
|
|
if (cancelToken.isCancelled || error.type == DioExceptionType.cancel) {
|
|
|
|
_finishAgentRequestStopped(agentMessage);
|
|
|
|
} else {
|
|
|
|
_finishAgentRequestError(agentMessage, '网络请求失败,请稍后重试');
|
|
|
|
}
|
|
|
|
} catch (_) {
|
|
|
|
_finishAgentRequestError(agentMessage, '请求失败,请稍后重试');
|
|
|
|
} finally {
|
|
|
|
if (identical(_agentCancelToken, cancelToken)) {
|
|
|
|
_agentCancelToken = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _finishAgentRequestError(_ChatMessage agentMessage, String message) {
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
|
|
agentMessage.text = message.isEmpty ? '请求失败,请稍后重试' : message;
|
|
|
|
agentMessage.card = null;
|
|
|
|
agentMessage.status = _MessageStatus.error;
|
|
|
|
_streaming = false;
|
|
|
|
_stopRequested = false;
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
}
|
|
|
|
|
|
|
|
void _finishAgentRequestStopped(_ChatMessage agentMessage) {
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
|
|
final String current = agentMessage.text.trimRight();
|
|
|
|
agentMessage.text = current.isEmpty ? '已停止。' : '$current (已停止)';
|
|
|
|
agentMessage.status = _MessageStatus.done;
|
|
|
|
_streaming = false;
|
|
|
|
_stopRequested = false;
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
}
|
|
|
|
|
|
|
|
void _retryAgentMessage(int agentIndex) {
|
|
|
|
if (_streaming || agentIndex <= 0 || agentIndex >= _messages.length) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
final _ChatMessage agentMessage = _messages[agentIndex];
|
|
|
|
if (agentMessage.role != _Role.agent ||
|
|
|
|
agentMessage.status != _MessageStatus.error) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
_ChatMessage? userMessage;
|
|
|
|
for (int index = agentIndex - 1; index >= 0; index--) {
|
|
|
|
if (_messages[index].role == _Role.user) {
|
|
|
|
userMessage = _messages[index];
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (userMessage == null) return;
|
|
|
|
|
|
|
|
final String messageId =
|
|
|
|
userMessage.clientMessageId ?? _newClientMessageId();
|
|
|
|
setState(() {
|
|
|
|
agentMessage.text = '';
|
|
|
|
agentMessage.card = null;
|
|
|
|
agentMessage.status = _MessageStatus.sending;
|
|
|
|
_streaming = true;
|
|
|
|
_stopRequested = false;
|
|
|
|
});
|
|
|
|
_persist();
|
|
|
|
unawaited(_requestAgentReply(
|
|
|
|
text: userMessage.text,
|
|
|
|
inputType: userMessage.inputType,
|
|
|
|
clientMessageId: messageId,
|
|
|
|
agentMessage: agentMessage,
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _maybeEnterReservationFlow(String text) {
|
|
...
|
...
|
@@ -380,7 +1133,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
ReservationImageParser.parse(trimmed);
|
|
|
|
if (!_isReservationText(result)) return false;
|
|
|
|
|
|
|
|
unawaited(_showReservationConfirm(_normalizeReservationDate(result)));
|
|
|
|
_startReservationConversation(_normalizeReservationDate(result));
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -443,6 +1196,10 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
|
|
|
|
void _stop() {
|
|
|
|
_stopRequested = true;
|
|
|
|
final CancelToken? cancelToken = _agentCancelToken;
|
|
|
|
if (cancelToken != null && !cancelToken.isCancelled) {
|
|
|
|
cancelToken.cancel('用户停止');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _containsAny(String q, List<String> keywords) {
|
|
...
|
...
|
@@ -853,7 +1610,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceTimer?.cancel();
|
|
|
|
|
|
|
|
if (_supportsWhisper) {
|
|
|
|
await _startCloudVoice(requestId);
|
|
|
|
await _startAgentVoice(requestId);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -861,6 +1618,151 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
await _startLegacyVoice();
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 使用 Agent 服务录音转写;录音文件只发送到 Agent 服务,不再调用百炼 ASR。
|
|
|
|
Future<void> _startAgentVoice(int requestId) async {
|
|
|
|
setState(() {
|
|
|
|
_voiceStage = _VoiceStage.preparing;
|
|
|
|
_voiceSeconds = 0;
|
|
|
|
_recognizedText = '';
|
|
|
|
_voiceHint = '正在准备录音…';
|
|
|
|
});
|
|
|
|
|
|
|
|
final AudioRecorder recorder = AudioRecorder();
|
|
|
|
_agentAudioRecorder = recorder;
|
|
|
|
try {
|
|
|
|
if (!await recorder.hasPermission()) {
|
|
|
|
final PermissionStatus status = await Permission.microphone.request();
|
|
|
|
if (!status.isGranted) {
|
|
|
|
throw const AgentApiException(0, '未获得麦克风权限');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
final Directory directory = await getTemporaryDirectory();
|
|
|
|
final String path =
|
|
|
|
'${directory.path}/agent_voice_${DateTime.now().microsecondsSinceEpoch}.wav';
|
|
|
|
await recorder.start(
|
|
|
|
const RecordConfig(
|
|
|
|
encoder: AudioEncoder.wav,
|
|
|
|
sampleRate: 16000,
|
|
|
|
numChannels: 1,
|
|
|
|
),
|
|
|
|
path: path,
|
|
|
|
);
|
|
|
|
_agentAudioPath = path;
|
|
|
|
|
|
|
|
if (!mounted || requestId != _voiceRequestId) {
|
|
|
|
await _cancelAgentVoice(dispose: true);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (!_voicePressActive) {
|
|
|
|
await _cancelAgentVoice(dispose: true);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
_voiceStage = _VoiceStage.recording;
|
|
|
|
_voiceSeconds = 0;
|
|
|
|
_recognizedText = '';
|
|
|
|
_voiceHint = '';
|
|
|
|
});
|
|
|
|
_voiceTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() => _voiceSeconds++);
|
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
debugPrint('Agent voice recording start failed: $error');
|
|
|
|
await _cancelAgentVoice(dispose: true);
|
|
|
|
if (mounted) {
|
|
|
|
_resetVoiceAfterError('无法启动录音,请检查麦克风权限');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _stopAgentVoice(int requestId) async {
|
|
|
|
_voiceTimer?.cancel();
|
|
|
|
final AudioRecorder? recorder = _agentAudioRecorder;
|
|
|
|
final String? recordedPath = _agentAudioPath;
|
|
|
|
_agentAudioRecorder = null;
|
|
|
|
_agentAudioPath = null;
|
|
|
|
if (recorder == null) return;
|
|
|
|
|
|
|
|
String? path;
|
|
|
|
try {
|
|
|
|
path = await recorder.stop();
|
|
|
|
} catch (_) {}
|
|
|
|
await _disposeAgentAudioRecorder(recorder);
|
|
|
|
|
|
|
|
final String finalPath = path ?? recordedPath ?? '';
|
|
|
|
if (!mounted || requestId != _voiceRequestId) {
|
|
|
|
_deleteAgentVoiceFile(finalPath);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (finalPath.isEmpty || !File(finalPath).existsSync()) {
|
|
|
|
_resetVoiceAfterError('没有录制到语音,请重试');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
_voiceStage = _VoiceStage.preparing;
|
|
|
|
_voiceSeconds = 0;
|
|
|
|
_recognizedText = '正在转写…';
|
|
|
|
_voiceHint = '正在转写语音…';
|
|
|
|
});
|
|
|
|
|
|
|
|
try {
|
|
|
|
final AgentCredentials? credentials = _agentCredentials();
|
|
|
|
if (credentials == null) {
|
|
|
|
throw const AgentApiException(0, '未获取到登录信息,请重新登录');
|
|
|
|
}
|
|
|
|
await _agentApi.prepare(credentials);
|
|
|
|
final String mimeType = lookupMimeType(finalPath) ?? 'audio/wav';
|
|
|
|
final String text = await _agentApi.transcribeAudio(
|
|
|
|
filePath: finalPath,
|
|
|
|
fileName: finalPath.split('/').last,
|
|
|
|
mimeType: mimeType,
|
|
|
|
);
|
|
|
|
_deleteAgentVoiceFile(finalPath);
|
|
|
|
if (!mounted || requestId != _voiceRequestId) return;
|
|
|
|
if (text.isEmpty) {
|
|
|
|
_resetVoiceAfterError('没有识别到内容,请重说');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
await _finishVoiceWithText(text, isVoice: true);
|
|
|
|
} on AgentApiException catch (error) {
|
|
|
|
_deleteAgentVoiceFile(finalPath);
|
|
|
|
if (mounted && requestId == _voiceRequestId) {
|
|
|
|
_resetVoiceAfterError(error.message);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
debugPrint('Agent voice transcription failed: $error');
|
|
|
|
_deleteAgentVoiceFile(finalPath);
|
|
|
|
if (mounted && requestId == _voiceRequestId) {
|
|
|
|
_resetVoiceAfterError('语音转写失败,请重试');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _cancelAgentVoice({bool dispose = false}) async {
|
|
|
|
_voicePressActive = false;
|
|
|
|
_voiceTimer?.cancel();
|
|
|
|
_voiceRequestId++;
|
|
|
|
final AudioRecorder? recorder = _agentAudioRecorder;
|
|
|
|
final String? path = _agentAudioPath;
|
|
|
|
_agentAudioRecorder = null;
|
|
|
|
_agentAudioPath = null;
|
|
|
|
if (recorder != null) {
|
|
|
|
try {
|
|
|
|
await recorder.cancel();
|
|
|
|
} catch (_) {}
|
|
|
|
if (dispose) {
|
|
|
|
try {
|
|
|
|
await recorder.dispose();
|
|
|
|
} catch (_) {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
_deleteAgentVoiceFile(path);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 移动端优先使用百炼 Paraformer 实时语音识别,失败时回退到离线 Whisper。
|
|
|
|
Future<void> _startCloudVoice(int requestId) async {
|
|
|
|
_usingCloudAsr = true;
|
|
...
|
...
|
@@ -1251,6 +2153,12 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
if (!mounted) return;
|
|
|
|
if (_voiceStage != _VoiceStage.recording) return;
|
|
|
|
|
|
|
|
if (_agentAudioRecorder != null) {
|
|
|
|
final int requestId = _voiceRequestId;
|
|
|
|
await _stopAgentVoice(requestId);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (_usingCloudAsr) {
|
|
|
|
final int requestId = _voiceRequestId;
|
|
|
|
await _stopCloudVoice(requestId);
|
|
...
|
...
|
@@ -1314,7 +2222,10 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _finishVoiceWithText(String? text) async {
|
|
|
|
Future<void> _finishVoiceWithText(
|
|
|
|
String? text, {
|
|
|
|
bool isVoice = true,
|
|
|
|
}) async {
|
|
|
|
_voicePressActive = false;
|
|
|
|
_voiceTimer?.cancel();
|
|
|
|
_usingNativeSpeech = false;
|
|
...
|
...
|
@@ -1346,6 +2257,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
setState(() {
|
|
|
|
_voiceStage = _VoiceStage.confirming;
|
|
|
|
_confirmText = simplified;
|
|
|
|
_nextMessageIsVoice = isVoice;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -1356,6 +2268,13 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_usingWhisper = false;
|
|
|
|
_usingNativeSpeech = false;
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
_nextMessageIsVoice = false;
|
|
|
|
final AudioRecorder? recorder = _agentAudioRecorder;
|
|
|
|
final String? voicePath = _agentAudioPath;
|
|
|
|
_agentAudioRecorder = null;
|
|
|
|
_agentAudioPath = null;
|
|
|
|
unawaited(_disposeAgentAudioRecorder(recorder, cancelFirst: true));
|
|
|
|
_deleteAgentVoiceFile(voicePath);
|
|
|
|
unawaited(_cloudAsrSession?.cancel());
|
|
|
|
_cloudAsrSession = null;
|
|
|
|
_whisperUpdateSub?.cancel();
|
|
...
|
...
|
@@ -1378,6 +2297,13 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceTimer?.cancel();
|
|
|
|
_voiceRequestId++;
|
|
|
|
_usingWhisper = false;
|
|
|
|
_nextMessageIsVoice = false;
|
|
|
|
final AudioRecorder? recorder = _agentAudioRecorder;
|
|
|
|
final String? voicePath = _agentAudioPath;
|
|
|
|
_agentAudioRecorder = null;
|
|
|
|
_agentAudioPath = null;
|
|
|
|
unawaited(_disposeAgentAudioRecorder(recorder, cancelFirst: true));
|
|
|
|
_deleteAgentVoiceFile(voicePath);
|
|
|
|
if (_usingCloudAsr) {
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
unawaited(_cloudAsrSession?.cancel());
|
|
...
|
...
|
@@ -1395,6 +2321,18 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
_voiceTimer?.cancel();
|
|
|
|
_voiceRequestId++;
|
|
|
|
_usingWhisper = false;
|
|
|
|
_nextMessageIsVoice = false;
|
|
|
|
if (_agentAudioRecorder != null) {
|
|
|
|
await _cancelAgentVoice();
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {
|
|
|
|
_voiceStage = _VoiceStage.idle;
|
|
|
|
_recognizedText = '';
|
|
|
|
_voiceHint = '';
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (_usingCloudAsr) {
|
|
|
|
_usingCloudAsr = false;
|
|
|
|
final ParaformerAsrSession? session = _cloudAsrSession;
|
|
...
|
...
|
@@ -1434,8 +2372,10 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
|
|
|
|
void _confirmVoice() {
|
|
|
|
final String text = _confirmText.trim();
|
|
|
|
final String inputType = _nextMessageIsVoice ? 'voice' : 'text';
|
|
|
|
_nextMessageIsVoice = false;
|
|
|
|
setState(() => _voiceStage = _VoiceStage.idle);
|
|
|
|
if (text.isNotEmpty) _send(text);
|
|
|
|
if (text.isNotEmpty) _send(text, inputType: inputType);
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _pickReservationImage() async {
|
|
...
|
...
|
@@ -1489,6 +2429,20 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
Future<void> _processReservationImageBytes(Uint8List bytes) async {
|
|
|
|
if (_imageProcessing) return;
|
|
|
|
setState(() => _imageProcessing = true);
|
|
|
|
|
|
|
|
// 保存图片到临时目录,以便在会话气泡中展示。
|
|
|
|
String? savedPath;
|
|
|
|
try {
|
|
|
|
final Directory dir = await getTemporaryDirectory();
|
|
|
|
final String fileName =
|
|
|
|
'reservation_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
|
|
final File f = File('${dir.path}/$fileName');
|
|
|
|
await f.writeAsBytes(bytes);
|
|
|
|
savedPath = f.path;
|
|
|
|
} catch (_) {
|
|
|
|
// 无关紧要,识别继续。
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
final QwenOcrClient ocr = QwenOcrClient(apiKey: _qwenApiKey);
|
|
|
|
final ReservationImageParseResult result =
|
|
...
|
...
|
@@ -1496,32 +2450,73 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
debugPrint('千问OCR识别成功: ${result.rawText.length}字');
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
setState(() => _imageProcessing = false);
|
|
|
|
|
|
|
|
if (!result.hasAny) {
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
const SnackBar(content: Text('没有从图片中识别到预订信息')),
|
|
|
|
);
|
|
|
|
// 没识别到任何字段,以会话形式告知用户。
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.user,
|
|
|
|
text: '我发来一张图片',
|
|
|
|
imagePath: savedPath,
|
|
|
|
));
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: '抱歉,我没有从这张图片中识别到预订信息。\n'
|
|
|
|
'请确认图片是否包含预订人、电话、日期和人数等信息,'
|
|
|
|
'也可以直接文字告诉我哦~',
|
|
|
|
));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
if (savedPath != null && File(savedPath).existsSync()) {
|
|
|
|
try {
|
|
|
|
await File(savedPath).delete();
|
|
|
|
} catch (_) {}
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
setState(() => _imageProcessing = false);
|
|
|
|
await _showReservationConfirm(_normalizeReservationDate(result));
|
|
|
|
// 走会话式预订流程,不弹窗。
|
|
|
|
_startReservationConversation(
|
|
|
|
_normalizeReservationDate(result),
|
|
|
|
imagePath: savedPath,
|
|
|
|
);
|
|
|
|
} on QwenOcrException catch (error) {
|
|
|
|
debugPrint('OCR失败: ${error.toString()}');
|
|
|
|
if (mounted) {
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
SnackBar(content: Text(error.message)),
|
|
|
|
);
|
|
|
|
setState(() => _imageProcessing = false);
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.user,
|
|
|
|
text: '我发来一张图片',
|
|
|
|
imagePath: savedPath,
|
|
|
|
));
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: '识别图片时出了点问题:${error.message}\n换个图片试试,或者直接文字告诉我预订信息~',
|
|
|
|
));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
debugPrint('OCR异常: $error');
|
|
|
|
if (mounted) {
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
const SnackBar(content: Text('图片识别失败,请重试')),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} finally {
|
|
|
|
if (mounted && _imageProcessing) {
|
|
|
|
setState(() => _imageProcessing = false);
|
|
|
|
setState(() {
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.user,
|
|
|
|
text: '我发来一张图片',
|
|
|
|
imagePath: savedPath,
|
|
|
|
));
|
|
|
|
_messages.add(_ChatMessage(
|
|
|
|
role: _Role.agent,
|
|
|
|
text: '图片识别失败,请换个图片重试,或者直接文字告诉我预订信息~',
|
|
|
|
));
|
|
|
|
});
|
|
|
|
_scrollToBottom();
|
|
|
|
_persist();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
...
|
...
|
@@ -1826,6 +2821,8 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
return _MessageBubble(
|
|
|
|
message: _messages[index],
|
|
|
|
themeColor: themeColor,
|
|
|
|
onSuggestion: _send,
|
|
|
|
onRetry: () => _retryAgentMessage(index),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
...
|
...
|
@@ -1869,6 +2866,7 @@ class _AgentChatPageState extends State<AgentChatPage> { |
|
|
|
initialText: _confirmText,
|
|
|
|
themeColor: themeColor,
|
|
|
|
onCancel: () {
|
|
|
|
_nextMessageIsVoice = false;
|
|
|
|
setState(() => _voiceStage = _VoiceStage.idle);
|
|
|
|
},
|
|
|
|
onConfirm: _confirmVoice,
|
|
...
|
...
|
@@ -2280,10 +3278,17 @@ class _ReservationConfirmSheetState extends State<_ReservationConfirmSheet> { |
|
|
|
}
|
|
|
|
|
|
|
|
class _MessageBubble extends StatelessWidget {
|
|
|
|
const _MessageBubble({required this.message, required this.themeColor});
|
|
|
|
const _MessageBubble({
|
|
|
|
required this.message,
|
|
|
|
required this.themeColor,
|
|
|
|
this.onSuggestion,
|
|
|
|
this.onRetry,
|
|
|
|
});
|
|
|
|
|
|
|
|
final _ChatMessage message;
|
|
|
|
final Color themeColor;
|
|
|
|
final ValueChanged<String>? onSuggestion;
|
|
|
|
final VoidCallback? onRetry;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
...
|
...
|
@@ -2293,68 +3298,113 @@ class _MessageBubble extends StatelessWidget { |
|
|
|
|
|
|
|
return Align(
|
|
|
|
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
|
|
|
child: Container(
|
|
|
|
margin: const EdgeInsets.symmetric(vertical: 5),
|
|
|
|
constraints: BoxConstraints(
|
|
|
|
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
|
|
|
),
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: bubbleColor,
|
|
|
|
borderRadius: BorderRadius.only(
|
|
|
|
topLeft: const Radius.circular(14),
|
|
|
|
topRight: const Radius.circular(14),
|
|
|
|
bottomLeft: Radius.circular(isUser ? 14 : 3),
|
|
|
|
bottomRight: Radius.circular(isUser ? 3 : 14),
|
|
|
|
child: GestureDetector(
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
onTap: message.status == _MessageStatus.error ? onRetry : null,
|
|
|
|
child: Container(
|
|
|
|
margin: const EdgeInsets.symmetric(vertical: 5),
|
|
|
|
constraints: BoxConstraints(
|
|
|
|
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
|
|
|
),
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: bubbleColor,
|
|
|
|
borderRadius: BorderRadius.only(
|
|
|
|
topLeft: const Radius.circular(14),
|
|
|
|
topRight: const Radius.circular(14),
|
|
|
|
bottomLeft: Radius.circular(isUser ? 14 : 3),
|
|
|
|
bottomRight: Radius.circular(isUser ? 3 : 14),
|
|
|
|
),
|
|
|
|
boxShadow: isUser
|
|
|
|
? null
|
|
|
|
: [
|
|
|
|
BoxShadow(
|
|
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
|
|
blurRadius: 6,
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
boxShadow: isUser
|
|
|
|
? null
|
|
|
|
: [
|
|
|
|
BoxShadow(
|
|
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
|
|
blurRadius: 6,
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
if (message.imagePath != null &&
|
|
|
|
File(message.imagePath!).existsSync()) ...[
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
|
|
child: ClipRRect(
|
|
|
|
borderRadius: BorderRadius.circular(10),
|
|
|
|
child: Image.file(File(message.imagePath!),
|
|
|
|
width: 180, height: 160, fit: BoxFit.cover),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
_buildContent(textColor),
|
|
|
|
if (message.card != null) ...[
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
GestureDetector(
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
onTap: () {
|
|
|
|
Navigator.of(context).push(
|
|
|
|
MaterialPageRoute(
|
|
|
|
builder: (_) => _AgentCardDetailPage(
|
|
|
|
card: message.card!,
|
|
|
|
themeColor: themeColor,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
_buildContent(textColor),
|
|
|
|
if (message.card != null) ...[
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
GestureDetector(
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
onTap: () {
|
|
|
|
Navigator.of(context).push(
|
|
|
|
MaterialPageRoute(
|
|
|
|
builder: (_) => _AgentCardDetailPage(
|
|
|
|
card: message.card!,
|
|
|
|
themeColor: themeColor,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
child: _CardView(card: message.card!, themeColor: themeColor),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
if (message.status == _MessageStatus.error)
|
|
|
|
const Padding(
|
|
|
|
padding: EdgeInsets.only(top: 6),
|
|
|
|
child: Text(
|
|
|
|
'发送失败,点击重发',
|
|
|
|
style: TextStyle(fontSize: 11, color: MyColours.color_E34D59),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
child: _CardView(
|
|
|
|
card: message.card!,
|
|
|
|
themeColor: themeColor,
|
|
|
|
onAction: onSuggestion),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
if (message.card != null &&
|
|
|
|
message.status == _MessageStatus.done &&
|
|
|
|
message.card!.followUps.isNotEmpty) ...[
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
Wrap(
|
|
|
|
spacing: 6,
|
|
|
|
runSpacing: 6,
|
|
|
|
children: message.card!.followUps.map((String item) {
|
|
|
|
return GestureDetector(
|
|
|
|
onTap: () => onSuggestion?.call(item),
|
|
|
|
child: Container(
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
horizontal: 10, vertical: 5),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: themeColor.withValues(alpha: 0.10),
|
|
|
|
borderRadius: BorderRadius.circular(14),
|
|
|
|
border: Border.all(
|
|
|
|
color: themeColor.withValues(alpha: 0.18)),
|
|
|
|
),
|
|
|
|
child: Text(item,
|
|
|
|
style: TextStyle(fontSize: 12, color: themeColor)),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}).toList(),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
if (message.status == _MessageStatus.error)
|
|
|
|
const Padding(
|
|
|
|
padding: EdgeInsets.only(top: 6),
|
|
|
|
child: Text(
|
|
|
|
'发送失败,点击重发',
|
|
|
|
style:
|
|
|
|
TextStyle(fontSize: 11, color: MyColours.color_E34D59),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
Text(
|
|
|
|
isUser ? _timeText(context) : '经营助手 · ${_timeText(context)}',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 10,
|
|
|
|
color: isUser ? Colors.white70 : MyColours.color_999999,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
Text(
|
|
|
|
isUser ? _timeText(context) : '经营助手 · ${_timeText(context)}',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 10,
|
|
|
|
color: isUser ? Colors.white70 : MyColours.color_999999,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
...
|
...
|
@@ -2440,10 +3490,12 @@ class _CaretState extends State<_Caret> with SingleTickerProviderStateMixin { |
|
|
|
}
|
|
|
|
|
|
|
|
class _CardView extends StatelessWidget {
|
|
|
|
const _CardView({required this.card, required this.themeColor});
|
|
|
|
const _CardView(
|
|
|
|
{required this.card, required this.themeColor, this.onAction});
|
|
|
|
|
|
|
|
final _AgentCard card;
|
|
|
|
final Color themeColor;
|
|
|
|
final ValueChanged<String>? onAction;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
...
|
...
|
@@ -2467,15 +3519,47 @@ class _CardView extends StatelessWidget { |
|
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
_buildItems(),
|
|
|
|
if (card.quickActions.isNotEmpty && onAction != null) ...[
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
Wrap(
|
|
|
|
spacing: 6,
|
|
|
|
runSpacing: 6,
|
|
|
|
children: card.quickActions.map((String action) {
|
|
|
|
final bool isPrimary =
|
|
|
|
action.contains('确认') || action.contains('好了');
|
|
|
|
return GestureDetector(
|
|
|
|
onTap: () => onAction!.call(action),
|
|
|
|
child: Container(
|
|
|
|
padding:
|
|
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: isPrimary
|
|
|
|
? themeColor
|
|
|
|
: themeColor.withValues(alpha: 0.10),
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
border: isPrimary
|
|
|
|
? null
|
|
|
|
: Border.all(
|
|
|
|
color: themeColor.withValues(alpha: 0.25)),
|
|
|
|
),
|
|
|
|
child: Text(action,
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 12,
|
|
|
|
fontWeight:
|
|
|
|
isPrimary ? FontWeight.w600 : FontWeight.w400,
|
|
|
|
color: isPrimary ? Colors.white : themeColor)),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}).toList(),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
if (card.kind != _CardKind.plain) ...[
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
'点击查看详情',
|
|
|
|
style: TextStyle(fontSize: 10, color: themeColor),
|
|
|
|
),
|
|
|
|
Text('点击查看详情',
|
|
|
|
style: TextStyle(fontSize: 10, color: themeColor)),
|
|
|
|
const SizedBox(width: 2),
|
|
|
|
Icon(Icons.chevron_right, size: 12, color: themeColor),
|
|
|
|
],
|
|
...
|
...
|
@@ -2528,6 +3612,30 @@ class _CardView extends StatelessWidget { |
|
|
|
);
|
|
|
|
}).toList(),
|
|
|
|
);
|
|
|
|
case _CardKind.reservationDraft:
|
|
|
|
return Column(
|
|
|
|
children: card.items.map((Map<String, dynamic> item) {
|
|
|
|
return Padding(
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
|
|
child: Row(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Text('${item['label'] ?? ''}',
|
|
|
|
style: const TextStyle(
|
|
|
|
fontSize: 11, color: MyColours.color_999999)),
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
Expanded(
|
|
|
|
child: Text('${item['value'] ?? ''}',
|
|
|
|
textAlign: TextAlign.right,
|
|
|
|
style: const TextStyle(
|
|
|
|
fontSize: 12,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
color: Color(0xFF303030)))),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}).toList(),
|
|
|
|
);
|
|
|
|
case _CardKind.dishRank:
|
|
|
|
return Column(
|
|
|
|
children: card.items
|
|
...
|
...
|
@@ -2797,6 +3905,14 @@ class _AgentCardDetailPage extends StatelessWidget { |
|
|
|
onTap: () => _openOrderDetail(context, '#2026091720'),
|
|
|
|
),
|
|
|
|
];
|
|
|
|
case _CardKind.reservationDraft:
|
|
|
|
return card.items.map((Map<String, dynamic> item) {
|
|
|
|
return _DetailRow(
|
|
|
|
'${item['label'] ?? ''}',
|
|
|
|
'${item['value'] ?? ''}',
|
|
|
|
color: themeColor,
|
|
|
|
);
|
|
|
|
}).toList();
|
|
|
|
case _CardKind.dishRank:
|
|
|
|
final List<_DetailRow> rows = <_DetailRow>[];
|
|
|
|
for (final Map<String, dynamic> item in card.items) {
|
...
|
...
|
|