Showing
8 changed files
with
3036 additions
and
97 deletions
build_agent_full.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +"""Complete rebuild of reservation conversation flow on clean git HEAD file.""" | ||
| 3 | +PATH = 'lib/pages/agent/agent_chat_page.dart' | ||
| 4 | + | ||
| 5 | +with open(PATH, 'r') as f: | ||
| 6 | + c = f.read() | ||
| 7 | + | ||
| 8 | +# ===== 1. Enum ===== | ||
| 9 | +c = c.replace('enum _CardKind { kpi, tableList, bookingList, orderList, dishRank, plain }', | ||
| 10 | + 'enum _CardKind { kpi, tableList, bookingList, orderList, dishRank, plain, reservationDraft }', 1) | ||
| 11 | + | ||
| 12 | +# ===== 2. _AgentCard ===== | ||
| 13 | +old = 'class _AgentCard {\n const _AgentCard({\n required this.kind,\n required this.title,\n required this.items,\n });\n\n final _CardKind kind;\n final String title;\n final List<Map<String, dynamic>> items;\n}' | ||
| 14 | +new = 'class _AgentCard {\n const _AgentCard({\n required this.kind,\n required this.title,\n required this.items,\n this.followUps = const <String>[],\n this.quickActions = const <String>[],\n });\n\n final _CardKind kind;\n final String title;\n final List<Map<String, dynamic>> items;\n final List<String> followUps;\n final List<String> quickActions;\n}' | ||
| 15 | +c = c.replace(old, new, 1) | ||
| 16 | + | ||
| 17 | +# ===== 3. _ChatMessage ===== | ||
| 18 | +old2 = 'class _ChatMessage {\n _ChatMessage({\n required this.role,\n required this.text,\n this.card,\n this.status = _MessageStatus.done,\n String? id,\n DateTime? ts,\n }) : id = id ?? \'${DateTime.now().microsecondsSinceEpoch}_${role.name}\',\n ts = ts ?? DateTime.now();\n\n final String id;\n final _Role role;\n String text;\n _AgentCard? card;\n _MessageStatus status;\n DateTime ts;\n}' | ||
| 19 | +new2 = 'class _ChatMessage {\n _ChatMessage({\n required this.role,\n required this.text,\n this.card,\n this.status = _MessageStatus.done,\n this.imagePath,\n String? id,\n DateTime? ts,\n }) : id = id ?? \'${DateTime.now().microsecondsSinceEpoch}_${role.name}\',\n ts = ts ?? DateTime.now();\n\n final String id;\n final _Role role;\n String text;\n _AgentCard? card;\n _MessageStatus status;\n DateTime ts;\n final String? imagePath;\n}' | ||
| 20 | +c = c.replace(old2, new2, 1) | ||
| 21 | + | ||
| 22 | +# ===== 4. _cardFromJson ===== | ||
| 23 | +old3 = ' _AgentCard? _cardFromJson(Map<String, dynamic> json) {\n final String kind = json[\'kind\']?.toString() ?? \'plain\';\n return _AgentCard(\n kind: _cardKindFromName(kind),\n title: json[\'title\']?.toString() ?? \'\',\n items: (json[\'items\'] as List? ?? const <dynamic>[])\n .cast<Map<String, dynamic>>(),\n );\n }' | ||
| 24 | +new3 = ' _AgentCard? _cardFromJson(Map<String, dynamic> json) {\n final String kind = json[\'kind\']?.toString() ?? \'plain\';\n return _AgentCard(\n kind: _cardKindFromName(kind),\n title: json[\'title\']?.toString() ?? \'\',\n items: (json[\'items\'] as List? ?? const <dynamic>[])\n .cast<Map<String, dynamic>>(),\n followUps: (json[\'followUps\'] as List? ?? const <dynamic>[])\n .whereType<String>()\n .toList(),\n quickActions: (json[\'quickActions\'] as List? ?? const <dynamic>[])\n .whereType<String>()\n .toList(),\n );\n }' | ||
| 25 | +c = c.replace(old3, new3, 1) | ||
| 26 | + | ||
| 27 | +# ===== 5. _cardKindFromName ===== | ||
| 28 | +c = c.replace(" case 'dish_rank':\n return _CardKind.dishRank;\n default:", | ||
| 29 | + " case 'dish_rank':\n return _CardKind.dishRank;\n case 'reservation_draft':\n return _CardKind.reservationDraft;\n default:", 1) | ||
| 30 | + | ||
| 31 | +# ===== 6. _cardKindName ===== | ||
| 32 | +c = c.replace(" case _CardKind.dishRank:\n return 'dish_rank';\n case _CardKind.plain:", | ||
| 33 | + " case _CardKind.dishRank:\n return 'dish_rank';\n case _CardKind.reservationDraft:\n return 'reservation_draft';\n case _CardKind.plain:", 1) | ||
| 34 | + | ||
| 35 | +# ===== 7. _toRecords serialization ===== | ||
| 36 | +old7 = " 'card': message.card == null\n ? null\n : <String, dynamic>{\n 'kind': _cardKindName(message.card!.kind),\n 'title': message.card!.title,\n 'items': message.card!.items,\n }," | ||
| 37 | +new7 = " 'card': message.card == null\n ? null\n : <String, dynamic>{\n 'kind': _cardKindName(message.card!.kind),\n 'title': message.card!.title,\n 'items': message.card!.items,\n 'followUps': message.card!.followUps,\n 'quickActions': message.card!.quickActions,\n }," | ||
| 38 | +c = c.replace(old7, new7, 1) | ||
| 39 | + | ||
| 40 | +# ===== 8. Reservation conversation methods (insert before _usingCloudAsr) ===== | ||
| 41 | +insert_marker = '\n bool _usingCloudAsr = false;' | ||
| 42 | +reservation_block = ''' | ||
| 43 | + /// ---- 预订会话式录入 ---- | ||
| 44 | + ReservationImageParseResult? _pendingReservation; | ||
| 45 | + bool _reservationAwaitingConfirm = false; | ||
| 46 | + | ||
| 47 | + bool _reservationRequiredComplete(ReservationImageParseResult result) { | ||
| 48 | + return result.name.isNotEmpty && result.phone.isNotEmpty && | ||
| 49 | + result.party.isNotEmpty && result.date.isNotEmpty; | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + ReservationImageParseResult _mergeReservation(ReservationImageParseResult old, ReservationImageParseResult next) { | ||
| 53 | + return ReservationImageParseResult( | ||
| 54 | + name: next.name.isNotEmpty ? next.name : old.name, | ||
| 55 | + sex: next.sex.isNotEmpty ? next.sex : old.sex, | ||
| 56 | + phone: next.phone.isNotEmpty ? next.phone : old.phone, | ||
| 57 | + party: next.party.isNotEmpty ? next.party : old.party, | ||
| 58 | + date: next.date.isNotEmpty ? next.date : old.date, | ||
| 59 | + meal: next.meal.isNotEmpty ? next.meal : old.meal, | ||
| 60 | + time: next.time.isNotEmpty ? next.time : old.time, | ||
| 61 | + table: next.table.isNotEmpty ? next.table : old.table, | ||
| 62 | + remark: next.remark.isNotEmpty ? next.remark : old.remark, | ||
| 63 | + rawText: next.rawText.isNotEmpty ? next.rawText : old.rawText, | ||
| 64 | + ); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) { | ||
| 68 | + final DateTime now = DateTime.now(); | ||
| 69 | + String date = result.date.trim(); | ||
| 70 | + if (date.isEmpty) return result; | ||
| 71 | + if (date == '今天' || date == '今日') { | ||
| 72 | + date = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; | ||
| 73 | + } else if (date == '明天' || date == '明日') { | ||
| 74 | + final DateTime t = now.add(const Duration(days: 1)); | ||
| 75 | + date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}'; | ||
| 76 | + } else if (date == '后天') { | ||
| 77 | + final DateTime t = now.add(const Duration(days: 2)); | ||
| 78 | + date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}'; | ||
| 79 | + } else if (RegExp(r'^\\d{1,2}月\\d{1,2}日$').hasMatch(date)) { | ||
| 80 | + final Match? m = RegExp(r'(\\d{1,2})月(\\d{1,2})日').firstMatch(date); | ||
| 81 | + if (m != null) { | ||
| 82 | + final int month = int.parse(m.group(1)!); | ||
| 83 | + final int day = int.parse(m.group(2)!); | ||
| 84 | + int year = now.year; | ||
| 85 | + if (DateTime(year, month, day).isBefore(DateTime(now.year, now.month, now.day))) year++; | ||
| 86 | + date = '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; | ||
| 87 | + } | ||
| 88 | + } | ||
| 89 | + return ReservationImageParseResult(name: result.name, sex: result.sex, phone: result.phone, | ||
| 90 | + party: result.party, date: date, meal: result.meal, time: result.time, | ||
| 91 | + table: result.table, remark: result.remark, rawText: result.rawText); | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + String _reservationIntro(ReservationImageParseResult result) { | ||
| 95 | + final List<String> parts = <String>[]; | ||
| 96 | + if (result.name.isNotEmpty) parts.add('预订人: ${result.name}'); | ||
| 97 | + if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}'); | ||
| 98 | + if (result.party.isNotEmpty) parts.add('人数: ${result.party}人'); | ||
| 99 | + if (result.date.isNotEmpty) parts.add('日期: ${result.date}'); | ||
| 100 | + if (parts.isEmpty) return '我从信息中没有识别到完整的预订信息,咱们一起补全吧。\\n请告诉我预订人姓名、电话、人数和日期。'; | ||
| 101 | + return '我从信息中识别到:\\n${parts.join('\\n')}\\n\\n如有遗漏,直接回复补充即可。'; | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + String _reservationSummaryText(ReservationImageParseResult result) { | ||
| 105 | + final List<String> parts = <String>[]; | ||
| 106 | + if (result.name.isNotEmpty) parts.add('姓名: ${result.name}'); | ||
| 107 | + if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}'); | ||
| 108 | + if (result.party.isNotEmpty) parts.add('人数: ${result.party}人'); | ||
| 109 | + if (result.date.isNotEmpty) parts.add('日期: ${result.date}'); | ||
| 110 | + if (result.meal.isNotEmpty) parts.add('时段: ${result.meal}'); | ||
| 111 | + if (result.time.isNotEmpty) parts.add('到店时间: ${result.time}'); | ||
| 112 | + if (result.table.isNotEmpty) parts.add('桌台: ${result.table}'); | ||
| 113 | + return parts.join('\\n'); | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + String _nextReservationQuestion(ReservationImageParseResult result) { | ||
| 117 | + final List<String> missing = <String>[]; | ||
| 118 | + if (result.name.isEmpty) missing.add('预订人姓名'); | ||
| 119 | + if (result.phone.isEmpty) missing.add('手机号'); | ||
| 120 | + if (result.party.isEmpty) missing.add('用餐人数'); | ||
| 121 | + if (result.date.isEmpty) missing.add('预订日期'); | ||
| 122 | + if (missing.isEmpty) return '信息已经齐全啦,确认一下就可以录入。'; | ||
| 123 | + return '还缺${missing.join('、')},直接回复我即可,例如"王先生 13800138000 8人 明晚"。'; | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + _AgentCard _reservationDraftCard(ReservationImageParseResult result, | ||
| 127 | + {List<String> quickActions = const <String>[]}) { | ||
| 128 | + final List<String> actions = quickActions.isNotEmpty ? quickActions | ||
| 129 | + : const <String>['确认录入', '好了,没问题', '修改信息', '取消']; | ||
| 130 | + return _AgentCard( | ||
| 131 | + kind: _CardKind.reservationDraft, | ||
| 132 | + title: '预订信息草稿', | ||
| 133 | + items: <Map<String, dynamic>>[ | ||
| 134 | + {'label': '姓名', 'value': result.name.isEmpty ? '待补充' : result.name}, | ||
| 135 | + {'label': '电话', 'value': result.phone.isEmpty ? '待补充' : result.phone}, | ||
| 136 | + {'label': '人数', 'value': result.party.isEmpty ? '待补充' : '${result.party}人'}, | ||
| 137 | + {'label': '日期', 'value': result.date.isEmpty ? '待补充' : result.date}, | ||
| 138 | + if (result.meal.isNotEmpty) {'label': '时段', 'value': result.meal}, | ||
| 139 | + if (result.time.isNotEmpty) {'label': '到店时间', 'value': result.time}, | ||
| 140 | + if (result.table.isNotEmpty) {'label': '桌台', 'value': result.table}, | ||
| 141 | + ], | ||
| 142 | + quickActions: _reservationAwaitingConfirm ? actions : const <String>[], | ||
| 143 | + ); | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + bool _isReservationConfirmText(String text) { | ||
| 147 | + final String t = text.trim(); | ||
| 148 | + if (RegExp(r'^(确认|确认录入|好了|好的|可以|可以录入|没问题|没问题了|录入|行|对|ok|yes)$', caseSensitive: false).hasMatch(t)) return true; | ||
| 149 | + if (t.contains('确认') && !t.contains('改') && !t.contains('换') && !t.contains('不是') && !t.contains('错了')) return true; | ||
| 150 | + if ((t.contains('好了') || t.contains('没问题')) && !t.contains('改') && !t.contains('不是')) return true; | ||
| 151 | + return false; | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + bool _isModificationIntent(String text) { | ||
| 155 | + final String t = text.trim(); | ||
| 156 | + return t == '修改信息' || t == '修改' || t == '改一下' || t == '改' || t == '我要修改' || t == '需要修改'; | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + String _describeChanges(ReservationImageParseResult before, ReservationImageParseResult after) { | ||
| 160 | + final List<String> fields = <String>[]; | ||
| 161 | + if (after.name.isNotEmpty && after.name != before.name) fields.add('姓名'); | ||
| 162 | + if (after.phone.isNotEmpty && after.phone != before.phone) fields.add('手机号'); | ||
| 163 | + if (after.party.isNotEmpty && after.party != before.party) fields.add('人数'); | ||
| 164 | + if (after.date.isNotEmpty && after.date != before.date) fields.add('日期'); | ||
| 165 | + if (after.meal.isNotEmpty && after.meal != before.meal) fields.add('时段'); | ||
| 166 | + if (after.time.isNotEmpty && after.time != before.time) fields.add('到店时间'); | ||
| 167 | + if (after.table.isNotEmpty && after.table != before.table) fields.add('桌台'); | ||
| 168 | + if (fields.isEmpty) return ''; | ||
| 169 | + return fields.join('、'); | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + Map<String, String> _reservationToFields(ReservationImageParseResult result) { | ||
| 173 | + return <String, String>{ | ||
| 174 | + 'name': result.name, 'sex': result.sex, 'phone': result.phone, | ||
| 175 | + 'party': result.party, 'date': result.date, 'meal': result.meal, | ||
| 176 | + 'time': result.time, 'table': result.table, 'remark': result.remark, | ||
| 177 | + }; | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + Future<void> _startReservationConversation(ReservationImageParseResult result, {String? imagePath}) async { | ||
| 181 | + if (!mounted) return; | ||
| 182 | + _pendingReservation = _normalizeReservationDate(result); | ||
| 183 | + _reservationAwaitingConfirm = _reservationRequiredComplete(_pendingReservation!); | ||
| 184 | + setState(() { | ||
| 185 | + _messages.add(_ChatMessage( | ||
| 186 | + role: _Role.user, | ||
| 187 | + text: imagePath != null && imagePath.isNotEmpty ? '我发来一张预订截图' : '语音录入预订信息', | ||
| 188 | + imagePath: imagePath, | ||
| 189 | + )); | ||
| 190 | + _messages.add(_ChatMessage( | ||
| 191 | + role: _Role.agent, | ||
| 192 | + text: _reservationIntro(_pendingReservation!), | ||
| 193 | + card: _reservationDraftCard(_pendingReservation!), | ||
| 194 | + )); | ||
| 195 | + }); | ||
| 196 | + _scrollToBottom(); | ||
| 197 | + _persist(); | ||
| 198 | + } | ||
| 199 | + | ||
| 200 | + Future<void> _handleReservationClarification(String text) async { | ||
| 201 | + final ReservationImageParseResult? current = _pendingReservation; | ||
| 202 | + if (current == null || !mounted) return; | ||
| 203 | + if (text.contains('取消') || text.contains('算了') || text.contains('不用')) { | ||
| 204 | + _pendingReservation = null; | ||
| 205 | + _reservationAwaitingConfirm = false; | ||
| 206 | + setState(() { | ||
| 207 | + _messages.add(_ChatMessage(role: _Role.user, text: text)); | ||
| 208 | + _messages.add(_ChatMessage(role: _Role.agent, text: '好的,已取消这次预订录入。还想查什么随时问我~')); | ||
| 209 | + }); | ||
| 210 | + _scrollToBottom(); _persist(); return; | ||
| 211 | + } | ||
| 212 | + setState(() { _messages.add(_ChatMessage(role: _Role.user, text: text)); }); | ||
| 213 | + _scrollToBottom(); _persist(); | ||
| 214 | + | ||
| 215 | + if (_reservationAwaitingConfirm) { | ||
| 216 | + if (_isReservationConfirmText(text)) { | ||
| 217 | + _pendingReservation = null; _reservationAwaitingConfirm = false; | ||
| 218 | + setState(() { _messages.add(_ChatMessage(role: _Role.agent, text: '好嘞,这就帮你带入预订信息,请在下个页面最后提交一下 👇')); }); | ||
| 219 | + _scrollToBottom(); _persist(); | ||
| 220 | + _openBookingWithFields(_reservationToFields(current)); | ||
| 221 | + return; | ||
| 222 | + } | ||
| 223 | + if (_isModificationIntent(text)) { | ||
| 224 | + setState(() { _messages.add(_ChatMessage(role: _Role.agent, | ||
| 225 | + text: '好的,请问要修改哪一项?直接告诉我就行,比如"手机号改成138xxxx""姓名改成张三"。', | ||
| 226 | + card: _reservationDraftCard(current))); }); | ||
| 227 | + _scrollToBottom(); _persist(); return; | ||
| 228 | + } | ||
| 229 | + final ReservationImageParseResult parsed = ReservationImageParser.parse(text); | ||
| 230 | + final bool hasUpdate = parsed.hasAny; | ||
| 231 | + final ReservationImageParseResult merged = _normalizeReservationDate(_mergeReservation(current, parsed)); | ||
| 232 | + _pendingReservation = merged; | ||
| 233 | + _reservationAwaitingConfirm = _reservationRequiredComplete(merged); | ||
| 234 | + final String changed = hasUpdate ? _describeChanges(current, parsed) : ''; | ||
| 235 | + setState(() { _messages.add(_ChatMessage(role: _Role.agent, | ||
| 236 | + text: hasUpdate | ||
| 237 | + ? (_reservationAwaitingConfirm ? '已修改$changed,确认无误回复"确认"即可录入。' : '已修改$changed,${_nextReservationQuestion(merged)}') | ||
| 238 | + : '没有识别到具体要修改的内容,能再说一遍吗?比如"手机号改成138xxxx""姓名改成张三"。', | ||
| 239 | + card: _reservationDraftCard(merged))); }); | ||
| 240 | + _scrollToBottom(); _persist(); return; | ||
| 241 | + } | ||
| 242 | + | ||
| 243 | + final ReservationImageParseResult parsed = ReservationImageParser.parse(text); | ||
| 244 | + final ReservationImageParseResult merged = _normalizeReservationDate(_mergeReservation(current, parsed)); | ||
| 245 | + if (_reservationRequiredComplete(merged)) { | ||
| 246 | + _pendingReservation = merged; _reservationAwaitingConfirm = true; | ||
| 247 | + setState(() { _messages.add(_ChatMessage(role: _Role.agent, | ||
| 248 | + text: '信息已经齐全:\\n${_reservationSummaryText(merged)}\\n\\n确认无误就回复"确认",我直接帮你录入;要修改哪一项也直接告诉我。', | ||
| 249 | + card: _reservationDraftCard(merged))); }); | ||
| 250 | + _scrollToBottom(); _persist(); return; | ||
| 251 | + } | ||
| 252 | + _pendingReservation = merged; _reservationAwaitingConfirm = false; | ||
| 253 | + setState(() { _messages.add(_ChatMessage(role: _Role.agent, | ||
| 254 | + text: _nextReservationQuestion(merged), card: _reservationDraftCard(merged))); }); | ||
| 255 | + _scrollToBottom(); _persist(); | ||
| 256 | + } | ||
| 257 | + | ||
| 258 | + void _maybeEnterReservationFlow(String text) { | ||
| 259 | + final ReservationImageParseResult parsed = ReservationImageParser.parse(text); | ||
| 260 | + if (parsed.hasAny) _startReservationConversation(parsed); | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + List<String> _followUpsFor(_CardKind kind) { | ||
| 264 | + switch (kind) { | ||
| 265 | + case _CardKind.kpi: return const <String>['昨天呢?', '近7天统计', '同比环比']; | ||
| 266 | + case _CardKind.tableList: return const <String>['今晚空桌呢?', '4人可用桌位', '桌位状态']; | ||
| 267 | + case _CardKind.bookingList: return const <String>['未确认的预订', '预订详情']; | ||
| 268 | + case _CardKind.orderList: return const <String>['支付状态', '指定桌位订单']; | ||
| 269 | + case _CardKind.dishRank: return const <String>['销量排行', '销售额排行', '滞销菜品']; | ||
| 270 | + case _CardKind.reservationDraft: | ||
| 271 | + case _CardKind.plain: return const <String>[]; | ||
| 272 | + } | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + _AgentCard _withFollowUps(_AgentCard card) { | ||
| 276 | + return _AgentCard( | ||
| 277 | + kind: card.kind, title: card.title, items: card.items, | ||
| 278 | + followUps: _followUpsFor(card.kind), quickActions: card.quickActions, | ||
| 279 | + ); | ||
| 280 | + } | ||
| 281 | +''' | ||
| 282 | + | ||
| 283 | +c = c.replace(insert_marker, reservation_block + insert_marker, 1) | ||
| 284 | + | ||
| 285 | +# ===== 9. _CardView: add onAction + quickActions buttons ===== | ||
| 286 | +old_cv = '''class _CardView extends StatelessWidget { | ||
| 287 | + const _CardView({required this.card, required this.themeColor}); | ||
| 288 | + | ||
| 289 | + final _AgentCard card; | ||
| 290 | + final Color themeColor;''' | ||
| 291 | +new_cv = '''class _CardView extends StatelessWidget { | ||
| 292 | + const _CardView({required this.card, required this.themeColor, this.onAction}); | ||
| 293 | + | ||
| 294 | + final _AgentCard card; | ||
| 295 | + final Color themeColor; | ||
| 296 | + final ValueChanged<String>? onAction;''' | ||
| 297 | +c = c.replace(old_cv, new_cv, 1) | ||
| 298 | + | ||
| 299 | +old_detail = ''' if (card.kind != _CardKind.plain) ...[ | ||
| 300 | + const SizedBox(height: 8), | ||
| 301 | + Row( | ||
| 302 | + mainAxisAlignment: MainAxisAlignment.end, | ||
| 303 | + children: [ | ||
| 304 | + Text( | ||
| 305 | + '点击查看详情', | ||
| 306 | + style: TextStyle(fontSize: 10, color: themeColor), | ||
| 307 | + ), | ||
| 308 | + const SizedBox(width: 2), | ||
| 309 | + Icon(Icons.chevron_right, size: 12, color: themeColor), | ||
| 310 | + ], | ||
| 311 | + ), | ||
| 312 | + ],''' | ||
| 313 | +new_detail = ''' if (card.quickActions.isNotEmpty && onAction != null) ...[ | ||
| 314 | + const SizedBox(height: 10), | ||
| 315 | + Wrap( | ||
| 316 | + spacing: 6, runSpacing: 6, | ||
| 317 | + children: card.quickActions.map((String action) { | ||
| 318 | + final bool isPrimary = action.contains('确认') || action.contains('好了'); | ||
| 319 | + return GestureDetector( | ||
| 320 | + onTap: () => onAction!.call(action), | ||
| 321 | + child: Container( | ||
| 322 | + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), | ||
| 323 | + decoration: BoxDecoration( | ||
| 324 | + color: isPrimary ? themeColor : themeColor.withValues(alpha: 0.10), | ||
| 325 | + borderRadius: BorderRadius.circular(16), | ||
| 326 | + border: isPrimary ? null : Border.all(color: themeColor.withValues(alpha: 0.25)), | ||
| 327 | + ), | ||
| 328 | + child: Text(action, style: TextStyle( | ||
| 329 | + fontSize: 12, fontWeight: isPrimary ? FontWeight.w600 : FontWeight.w400, | ||
| 330 | + color: isPrimary ? Colors.white : themeColor)), | ||
| 331 | + ), | ||
| 332 | + ); | ||
| 333 | + }).toList(), | ||
| 334 | + ), | ||
| 335 | + ], | ||
| 336 | + if (card.kind != _CardKind.plain) ...[ | ||
| 337 | + const SizedBox(height: 8), | ||
| 338 | + Row( | ||
| 339 | + mainAxisAlignment: MainAxisAlignment.end, | ||
| 340 | + children: [ | ||
| 341 | + Text('点击查看详情', style: TextStyle(fontSize: 10, color: themeColor)), | ||
| 342 | + const SizedBox(width: 2), | ||
| 343 | + Icon(Icons.chevron_right, size: 12, color: themeColor), | ||
| 344 | + ], | ||
| 345 | + ), | ||
| 346 | + ],''' | ||
| 347 | +c = c.replace(old_detail, new_detail, 1) | ||
| 348 | + | ||
| 349 | +# ===== 10. _MessageBubble: onAction + imagePath + followUps ===== | ||
| 350 | +old_mb = ' child: _CardView(card: message.card!, themeColor: themeColor),' | ||
| 351 | +new_mb = ' child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion),' | ||
| 352 | +c = c.replace(old_mb, new_mb, 1) | ||
| 353 | + | ||
| 354 | +old_img = ' _buildContent(textColor),' | ||
| 355 | +new_img = ''' if (message.imagePath != null && File(message.imagePath!).existsSync()) ...[ | ||
| 356 | + Padding( | ||
| 357 | + padding: const EdgeInsets.only(bottom: 8), | ||
| 358 | + child: ClipRRect( | ||
| 359 | + borderRadius: BorderRadius.circular(10), | ||
| 360 | + child: Image.file(File(message.imagePath!), width: 180, height: 160, fit: BoxFit.cover), | ||
| 361 | + ), | ||
| 362 | + ), | ||
| 363 | + ], | ||
| 364 | + _buildContent(textColor),''' | ||
| 365 | +c = c.replace(old_img, new_img, 1) | ||
| 366 | + | ||
| 367 | +# Add followUps rendering after card | ||
| 368 | +old_card_block = ''' if (message.card != null) ...[ | ||
| 369 | + const SizedBox(height: 8), | ||
| 370 | + GestureDetector( | ||
| 371 | + behavior: HitTestBehavior.opaque, | ||
| 372 | + onTap: () { | ||
| 373 | + Navigator.of(context).push( | ||
| 374 | + MaterialPageRoute( | ||
| 375 | + builder: (_) => _AgentCardDetailPage( | ||
| 376 | + card: message.card!, | ||
| 377 | + themeColor: themeColor, | ||
| 378 | + ), | ||
| 379 | + ), | ||
| 380 | + ); | ||
| 381 | + }, | ||
| 382 | + child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion), | ||
| 383 | + ), | ||
| 384 | + ],''' | ||
| 385 | + | ||
| 386 | +new_card_block = ''' if (message.card != null) ...[ | ||
| 387 | + const SizedBox(height: 8), | ||
| 388 | + GestureDetector( | ||
| 389 | + behavior: HitTestBehavior.opaque, | ||
| 390 | + onTap: () { | ||
| 391 | + Navigator.of(context).push( | ||
| 392 | + MaterialPageRoute( | ||
| 393 | + builder: (_) => _AgentCardDetailPage( | ||
| 394 | + card: message.card!, | ||
| 395 | + themeColor: themeColor, | ||
| 396 | + ), | ||
| 397 | + ), | ||
| 398 | + ); | ||
| 399 | + }, | ||
| 400 | + child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion), | ||
| 401 | + ), | ||
| 402 | + ], | ||
| 403 | + if (message.card != null && | ||
| 404 | + message.status == _MessageStatus.done && | ||
| 405 | + message.card!.followUps.isNotEmpty) ...[ | ||
| 406 | + const SizedBox(height: 8), | ||
| 407 | + Wrap( | ||
| 408 | + spacing: 6, runSpacing: 6, | ||
| 409 | + children: message.card!.followUps.map((String item) { | ||
| 410 | + return GestureDetector( | ||
| 411 | + onTap: () => onSuggestion(item), | ||
| 412 | + child: Container( | ||
| 413 | + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), | ||
| 414 | + decoration: BoxDecoration( | ||
| 415 | + color: themeColor.withValues(alpha: 0.10), | ||
| 416 | + borderRadius: BorderRadius.circular(14), | ||
| 417 | + border: Border.all(color: themeColor.withValues(alpha: 0.18)), | ||
| 418 | + ), | ||
| 419 | + child: Text(item, style: TextStyle(fontSize: 12, color: themeColor)), | ||
| 420 | + ), | ||
| 421 | + ); | ||
| 422 | + }).toList(), | ||
| 423 | + ), | ||
| 424 | + ],''' | ||
| 425 | + | ||
| 426 | +if old_card_block in c: | ||
| 427 | + c = c.replace(old_card_block, new_card_block, 1) | ||
| 428 | +else: | ||
| 429 | + print("WARNING: card block not found for replacement") | ||
| 430 | + | ||
| 431 | +with open(PATH, 'w') as f: | ||
| 432 | + f.write(c) | ||
| 433 | + | ||
| 434 | +print(f"SUCCESS: {c.count(chr(10))} lines") |
build_agent_patch.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +"""Rebuild the reservation conversation flow on agent_chat_page.dart.""" | ||
| 3 | +import sys | ||
| 4 | + | ||
| 5 | +PATH = 'lib/pages/agent/agent_chat_page.dart' | ||
| 6 | + | ||
| 7 | +with open(PATH, 'r') as f: | ||
| 8 | + content = f.read() | ||
| 9 | + | ||
| 10 | +# ============================================================ | ||
| 11 | +# BLOCK A: Add the entire reservation conversation code block | ||
| 12 | +# Insert right before "Widget build(BuildContext context)" in _AgentChatPageState | ||
| 13 | +# ============================================================ | ||
| 14 | + | ||
| 15 | +# Find where to insert the new methods | ||
| 16 | +insert_marker = '\n bool _usingCloudAsr = false;' | ||
| 17 | +if insert_marker not in content: | ||
| 18 | + print("ERROR: insert_marker not found") | ||
| 19 | + sys.exit(1) | ||
| 20 | + | ||
| 21 | +# The new state variables and methods to insert | ||
| 22 | +new_block = ''' | ||
| 23 | + /// ---- 预订会话式录入 ---- | ||
| 24 | + ReservationImageParseResult? _pendingReservation; | ||
| 25 | + bool _reservationAwaitingConfirm = false; | ||
| 26 | + | ||
| 27 | + /// 辅助:图片/文字预订的必填字段是否齐全。 | ||
| 28 | + bool _reservationRequiredComplete(ReservationImageParseResult result) { | ||
| 29 | + return result.name.isNotEmpty && | ||
| 30 | + result.phone.isNotEmpty && | ||
| 31 | + result.party.isNotEmpty && | ||
| 32 | + result.date.isNotEmpty; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + /// 合并两次解析结果:新解析的非空字段覆盖旧值。 | ||
| 36 | + ReservationImageParseResult _mergeReservation( | ||
| 37 | + ReservationImageParseResult old, | ||
| 38 | + ReservationImageParseResult next, | ||
| 39 | + ) { | ||
| 40 | + return ReservationImageParseResult( | ||
| 41 | + name: next.name.isNotEmpty ? next.name : old.name, | ||
| 42 | + sex: next.sex.isNotEmpty ? next.sex : old.sex, | ||
| 43 | + phone: next.phone.isNotEmpty ? next.phone : old.phone, | ||
| 44 | + party: next.party.isNotEmpty ? next.party : old.party, | ||
| 45 | + date: next.date.isNotEmpty ? next.date : old.date, | ||
| 46 | + meal: next.meal.isNotEmpty ? next.meal : old.meal, | ||
| 47 | + time: next.time.isNotEmpty ? next.time : old.time, | ||
| 48 | + table: next.table.isNotEmpty ? next.table : old.table, | ||
| 49 | + remark: next.remark.isNotEmpty ? next.remark : old.remark, | ||
| 50 | + rawText: next.rawText.isNotEmpty ? next.rawText : old.rawText, | ||
| 51 | + ); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + /// 将相对日期转为未来具体日期。 | ||
| 55 | + ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) { | ||
| 56 | + final DateTime now = DateTime.now(); | ||
| 57 | + String date = result.date.trim(); | ||
| 58 | + if (date.isEmpty) return result; | ||
| 59 | + | ||
| 60 | + if (date == '今天' || date == '今日') { | ||
| 61 | + date = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; | ||
| 62 | + } else if (date == '明天' || date == '明日') { | ||
| 63 | + final DateTime t = now.add(const Duration(days: 1)); | ||
| 64 | + date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}'; | ||
| 65 | + } else if (date == '后天') { | ||
| 66 | + final DateTime t = now.add(const Duration(days: 2)); | ||
| 67 | + date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}'; | ||
| 68 | + } else if (RegExp(r'^\\d{1,2}月\\d{1,2}日$').hasMatch(date)) { | ||
| 69 | + final Match? m = RegExp(r'(\\d{1,2})月(\\d{1,2})日').firstMatch(date); | ||
| 70 | + if (m != null) { | ||
| 71 | + final int month = int.parse(m.group(1)!); | ||
| 72 | + final int day = int.parse(m.group(2)!); | ||
| 73 | + int year = now.year; | ||
| 74 | + final DateTime target = DateTime(year, month, day); | ||
| 75 | + if (target.isBefore(DateTime(now.year, now.month, now.day))) { | ||
| 76 | + year++; | ||
| 77 | + } | ||
| 78 | + date = '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + return ReservationImageParseResult( | ||
| 82 | + name: result.name, sex: result.sex, phone: result.phone, | ||
| 83 | + party: result.party, date: date, meal: result.meal, | ||
| 84 | + time: result.time, table: result.table, remark: result.remark, | ||
| 85 | + rawText: result.rawText, | ||
| 86 | + ); | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + String _reservationIntro(ReservationImageParseResult result) { | ||
| 90 | + final List<String> parts = <String>[]; | ||
| 91 | + if (result.name.isNotEmpty) parts.add('预订人: ${result.name}'); | ||
| 92 | + if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}'); | ||
| 93 | + if (result.party.isNotEmpty) parts.add('人数: ${result.party}人'); | ||
| 94 | + if (result.date.isNotEmpty) parts.add('日期: ${result.date}'); | ||
| 95 | + if (parts.isEmpty) { | ||
| 96 | + return '我从截图中没有识别到完整的预订信息,咱们一起补全吧。\\n请告诉我预订人姓名、电话、人数和日期。'; | ||
| 97 | + } | ||
| 98 | + return '我从截图中识别到以下信息,请核对:\\n${parts.join('\\n')}\\n\\n如有遗漏,直接回复补充即可。'; | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + String _reservationSummaryText(ReservationImageParseResult result) { | ||
| 102 | + final List<String> parts = <String>[]; | ||
| 103 | + if (result.name.isNotEmpty) parts.add('姓名: ${result.name}'); | ||
| 104 | + if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}'); | ||
| 105 | + if (result.party.isNotEmpty) parts.add('人数: ${result.party}人'); | ||
| 106 | + if (result.date.isNotEmpty) parts.add('日期: ${result.date}'); | ||
| 107 | + if (result.meal.isNotEmpty) parts.add('时段: ${result.meal}'); | ||
| 108 | + if (result.time.isNotEmpty) parts.add('到店时间: ${result.time}'); | ||
| 109 | + if (result.table.isNotEmpty) parts.add('桌台: ${result.table}'); | ||
| 110 | + return parts.join('\\n'); | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + String _nextReservationQuestion(ReservationImageParseResult result) { | ||
| 114 | + final List<String> missing = <String>[]; | ||
| 115 | + if (result.name.isEmpty) missing.add('预订人姓名'); | ||
| 116 | + if (result.phone.isEmpty) missing.add('手机号'); | ||
| 117 | + if (result.party.isEmpty) missing.add('用餐人数'); | ||
| 118 | + if (result.date.isEmpty) missing.add('预订日期'); | ||
| 119 | + if (missing.isEmpty) return '信息已经齐全啦,确认一下就可以录入。'; | ||
| 120 | + return '还缺${missing.join('、')},直接回复我即可,例如"王先生 13800138000 8人 明晚"。'; | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + _AgentCard _reservationDraftCard(ReservationImageParseResult result, | ||
| 124 | + {List<String> quickActions = const <String>[]}) { | ||
| 125 | + final List<String> actions = quickActions.isNotEmpty | ||
| 126 | + ? quickActions | ||
| 127 | + : const <String>['确认录入', '好了,没问题', '修改信息', '取消']; | ||
| 128 | + return _AgentCard( | ||
| 129 | + kind: _CardKind.reservationDraft, | ||
| 130 | + title: '预订信息草稿', | ||
| 131 | + items: <Map<String, dynamic>>[ | ||
| 132 | + {'label': '姓名', 'value': result.name.isEmpty ? '待补充' : result.name}, | ||
| 133 | + {'label': '电话', 'value': result.phone.isEmpty ? '待补充' : result.phone}, | ||
| 134 | + {'label': '人数', 'value': result.party.isEmpty ? '待补充' : '${result.party}人'}, | ||
| 135 | + {'label': '日期', 'value': result.date.isEmpty ? '待补充' : result.date}, | ||
| 136 | + if (result.meal.isNotEmpty) {'label': '时段', 'value': result.meal}, | ||
| 137 | + if (result.time.isNotEmpty) {'label': '到店时间', 'value': result.time}, | ||
| 138 | + if (result.table.isNotEmpty) {'label': '桌台', 'value': result.table}, | ||
| 139 | + ], | ||
| 140 | + quickActions: _reservationAwaitingConfirm ? actions : const <String>[], | ||
| 141 | + ); | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + bool _isReservationConfirmText(String text) { | ||
| 145 | + final String trimmed = text.trim(); | ||
| 146 | + if (RegExp(r'^(确认|确认录入|好了|好的|可以|可以录入|没问题|没问题了|录入|行|对|ok|yes)$', caseSensitive: false) | ||
| 147 | + .hasMatch(trimmed)) { | ||
| 148 | + return true; | ||
| 149 | + } | ||
| 150 | + if (trimmed.contains('确认') && !trimmed.contains('改') && !trimmed.contains('换') && !trimmed.contains('不是') && !trimmed.contains('错了')) { | ||
| 151 | + return true; | ||
| 152 | + } | ||
| 153 | + if ((trimmed.contains('好了') || trimmed.contains('没问题')) && !trimmed.contains('改') && !trimmed.contains('不是')) { | ||
| 154 | + return true; | ||
| 155 | + } | ||
| 156 | + return false; | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + bool _isModificationIntent(String text) { | ||
| 160 | + final String trimmed = text.trim(); | ||
| 161 | + return trimmed == '修改信息' || trimmed == '修改' || trimmed == '改一下' || trimmed == '改' || trimmed == '我要修改' || trimmed == '需要修改'; | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + String _describeChanges(ReservationImageParseResult before, ReservationImageParseResult after) { | ||
| 165 | + final List<String> fields = <String>[]; | ||
| 166 | + if (after.name.isNotEmpty && after.name != before.name) fields.add('姓名'); | ||
| 167 | + if (after.phone.isNotEmpty && after.phone != before.phone) fields.add('手机号'); | ||
| 168 | + if (after.party.isNotEmpty && after.party != before.party) fields.add('人数'); | ||
| 169 | + if (after.date.isNotEmpty && after.date != before.date) fields.add('日期'); | ||
| 170 | + if (after.meal.isNotEmpty && after.meal != before.meal) fields.add('时段'); | ||
| 171 | + if (after.time.isNotEmpty && after.time != before.time) fields.add('到店时间'); | ||
| 172 | + if (after.table.isNotEmpty && after.table != before.table) fields.add('桌台'); | ||
| 173 | + if (fields.isEmpty) return ''; | ||
| 174 | + return fields.join('、'); | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + Map<String, String> _reservationToFields(ReservationImageParseResult result) { | ||
| 178 | + return <String, String>{ | ||
| 179 | + 'name': result.name, 'sex': result.sex, 'phone': result.phone, | ||
| 180 | + 'party': result.party, 'date': result.date, 'meal': result.meal, | ||
| 181 | + 'time': result.time, 'table': result.table, 'remark': result.remark, | ||
| 182 | + }; | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + Future<void> _startReservationConversation(ReservationImageParseResult result, {String? imagePath}) async { | ||
| 186 | + if (!mounted) return; | ||
| 187 | + _pendingReservation = _normalizeReservationDate(result); | ||
| 188 | + _reservationAwaitingConfirm = _reservationRequiredComplete(_pendingReservation!); | ||
| 189 | + setState(() { | ||
| 190 | + _messages.add(_ChatMessage( | ||
| 191 | + role: _Role.user, | ||
| 192 | + text: imagePath != null && imagePath.isNotEmpty ? '我发来一张预订截图' : '语音录入预订信息', | ||
| 193 | + imagePath: imagePath, | ||
| 194 | + )); | ||
| 195 | + _messages.add(_ChatMessage( | ||
| 196 | + role: _Role.agent, | ||
| 197 | + text: _reservationIntro(_pendingReservation!), | ||
| 198 | + card: _reservationDraftCard(_pendingReservation!), | ||
| 199 | + )); | ||
| 200 | + }); | ||
| 201 | + _scrollToBottom(); | ||
| 202 | + _persist(); | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + Future<void> _handleReservationClarification(String text) async { | ||
| 206 | + final ReservationImageParseResult? current = _pendingReservation; | ||
| 207 | + if (current == null || !mounted) return; | ||
| 208 | + | ||
| 209 | + if (text.contains('取消') || text.contains('算了') || text.contains('不用')) { | ||
| 210 | + _pendingReservation = null; | ||
| 211 | + _reservationAwaitingConfirm = false; | ||
| 212 | + setState(() { | ||
| 213 | + _messages.add(_ChatMessage(role: _Role.user, text: text)); | ||
| 214 | + _messages.add(_ChatMessage(role: _Role.agent, text: '好的,已取消这次预订录入。还想查什么随时问我~')); | ||
| 215 | + }); | ||
| 216 | + _scrollToBottom(); | ||
| 217 | + _persist(); | ||
| 218 | + return; | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + setState(() { _messages.add(_ChatMessage(role: _Role.user, text: text)); }); | ||
| 222 | + _scrollToBottom(); | ||
| 223 | + _persist(); | ||
| 224 | + | ||
| 225 | + if (_reservationAwaitingConfirm) { | ||
| 226 | + if (_isReservationConfirmText(text)) { | ||
| 227 | + _pendingReservation = null; | ||
| 228 | + _reservationAwaitingConfirm = false; | ||
| 229 | + setState(() { | ||
| 230 | + _messages.add(_ChatMessage(role: _Role.agent, text: '好嘞,这就帮你带入预订信息,请在下个页面最后提交一下 👇')); | ||
| 231 | + }); | ||
| 232 | + _scrollToBottom(); | ||
| 233 | + _persist(); | ||
| 234 | + _openBookingWithFields(_reservationToFields(current)); | ||
| 235 | + return; | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + if (_isModificationIntent(text)) { | ||
| 239 | + setState(() { | ||
| 240 | + _messages.add(_ChatMessage(role: _Role.agent, | ||
| 241 | + text: '好的,请问要修改哪一项?直接告诉我就行,比如"手机号改成138xxxx""姓名改成张三"。', | ||
| 242 | + card: _reservationDraftCard(current), | ||
| 243 | + )); | ||
| 244 | + }); | ||
| 245 | + _scrollToBottom(); | ||
| 246 | + _persist(); | ||
| 247 | + return; | ||
| 248 | + } | ||
| 249 | + | ||
| 250 | + final ReservationImageParseResult parsed = ReservationImageParser.parse(text); | ||
| 251 | + final bool hasUpdate = parsed.hasAny; | ||
| 252 | + final ReservationImageParseResult merged = _normalizeReservationDate(_mergeReservation(current, parsed)); | ||
| 253 | + _pendingReservation = merged; | ||
| 254 | + _reservationAwaitingConfirm = _reservationRequiredComplete(merged); | ||
| 255 | + final String changed = hasUpdate ? _describeChanges(current, parsed) : ''; | ||
| 256 | + setState(() { | ||
| 257 | + _messages.add(_ChatMessage(role: _Role.agent, | ||
| 258 | + text: hasUpdate | ||
| 259 | + ? (_reservationAwaitingConfirm ? '已修改$changed,确认无误回复"确认"即可录入。' : '已修改$changed,${_nextReservationQuestion(merged)}') | ||
| 260 | + : '没有识别到具体要修改的内容,能再说一遍吗?比如"手机号改成138xxxx""姓名改成张三"。', | ||
| 261 | + card: _reservationDraftCard(merged), | ||
| 262 | + )); | ||
| 263 | + }); | ||
| 264 | + _scrollToBottom(); | ||
| 265 | + _persist(); | ||
| 266 | + return; | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + final ReservationImageParseResult parsed = ReservationImageParser.parse(text); | ||
| 270 | + final ReservationImageParseResult merged = _normalizeReservationDate(_mergeReservation(current, parsed)); | ||
| 271 | + if (_reservationRequiredComplete(merged)) { | ||
| 272 | + _pendingReservation = merged; | ||
| 273 | + _reservationAwaitingConfirm = true; | ||
| 274 | + setState(() { | ||
| 275 | + _messages.add(_ChatMessage(role: _Role.agent, | ||
| 276 | + text: '信息已经齐全:\\n${_reservationSummaryText(merged)}\\n\\n确认无误就回复"确认",我直接帮你录入;要修改哪一项也直接告诉我。', | ||
| 277 | + card: _reservationDraftCard(merged), | ||
| 278 | + )); | ||
| 279 | + }); | ||
| 280 | + _scrollToBottom(); | ||
| 281 | + _persist(); | ||
| 282 | + return; | ||
| 283 | + } | ||
| 284 | + _pendingReservation = merged; | ||
| 285 | + _reservationAwaitingConfirm = false; | ||
| 286 | + setState(() { | ||
| 287 | + _messages.add(_ChatMessage(role: _Role.agent, | ||
| 288 | + text: _nextReservationQuestion(merged), | ||
| 289 | + card: _reservationDraftCard(merged), | ||
| 290 | + )); | ||
| 291 | + }); | ||
| 292 | + _scrollToBottom(); | ||
| 293 | + _persist(); | ||
| 294 | + } | ||
| 295 | + | ||
| 296 | + void _maybeEnterReservationFlow(String text) { | ||
| 297 | + final ReservationImageParseResult parsed = ReservationImageParser.parse(text); | ||
| 298 | + if (parsed.hasAny) { | ||
| 299 | + _startReservationConversation(parsed); | ||
| 300 | + } | ||
| 301 | + } | ||
| 302 | + | ||
| 303 | + // ---- 追问词条 & 卡片辅助 ---- | ||
| 304 | + List<String> _followUpsFor(_CardKind kind) { | ||
| 305 | + switch (kind) { | ||
| 306 | + case _CardKind.kpi: return const <String>['昨天呢?', '近7天统计', '同比环比']; | ||
| 307 | + case _CardKind.tableList: return const <String>['今晚空桌呢?', '4人可用桌位', '桌位状态']; | ||
| 308 | + case _CardKind.bookingList: return const <String>['未确认的预订', '预订详情']; | ||
| 309 | + case _CardKind.orderList: return const <String>['支付状态', '指定桌位订单']; | ||
| 310 | + case _CardKind.dishRank: return const <String>['销量排行', '销售额排行', '滞销菜品']; | ||
| 311 | + case _CardKind.reservationDraft: | ||
| 312 | + case _CardKind.plain: return const <String>[]; | ||
| 313 | + } | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + _AgentCard _withFollowUps(_AgentCard card) { | ||
| 317 | + return _AgentCard( | ||
| 318 | + kind: card.kind, title: card.title, items: card.items, | ||
| 319 | + followUps: _followUpsFor(card.kind), quickActions: card.quickActions, | ||
| 320 | + ); | ||
| 321 | + } | ||
| 322 | +''' | ||
| 323 | + | ||
| 324 | +content = content.replace(insert_marker, new_block + insert_marker, 1) | ||
| 325 | + | ||
| 326 | +# ============================================================ | ||
| 327 | +# BLOCK B: Update _toRecords serialization | ||
| 328 | +# ============================================================ | ||
| 329 | +old_ser = " 'card': message.card == null\n ? null\n : <String, dynamic>{\n 'kind': _cardKindName(message.card!.kind),\n 'title': message.card!.title,\n 'items': message.card!.items,\n }," | ||
| 330 | +new_ser = " 'card': message.card == null\n ? null\n : <String, dynamic>{\n 'kind': _cardKindName(message.card!.kind),\n 'title': message.card!.title,\n 'items': message.card!.items,\n 'followUps': message.card!.followUps,\n 'quickActions': message.card!.quickActions,\n }," | ||
| 331 | +content = content.replace(old_ser, new_ser, 1) | ||
| 332 | + | ||
| 333 | +# ============================================================ | ||
| 334 | +# BLOCK C: Update _CardView to support onAction + quickActions | ||
| 335 | +# ============================================================ | ||
| 336 | +old_cv = '''class _CardView extends StatelessWidget { | ||
| 337 | + const _CardView({required this.card, required this.themeColor}); | ||
| 338 | + | ||
| 339 | + final _AgentCard card; | ||
| 340 | + final Color themeColor;''' | ||
| 341 | +new_cv = '''class _CardView extends StatelessWidget { | ||
| 342 | + const _CardView({required this.card, required this.themeColor, this.onAction}); | ||
| 343 | + | ||
| 344 | + final _AgentCard card; | ||
| 345 | + final Color themeColor; | ||
| 346 | + final ValueChanged<String>? onAction;''' | ||
| 347 | +content = content.replace(old_cv, new_cv, 1) | ||
| 348 | + | ||
| 349 | +# Add quickActions buttons rendering - find the "点击查看详情" section base | ||
| 350 | +old_detail_tap = ''' if (card.kind != _CardKind.plain) ...[ | ||
| 351 | + const SizedBox(height: 8), | ||
| 352 | + Row( | ||
| 353 | + mainAxisAlignment: MainAxisAlignment.end, | ||
| 354 | + children: [ | ||
| 355 | + Text( | ||
| 356 | + '点击查看详情', | ||
| 357 | + style: TextStyle(fontSize: 10, color: themeColor), | ||
| 358 | + ), | ||
| 359 | + const SizedBox(width: 2), | ||
| 360 | + Icon(Icons.chevron_right, size: 12, color: themeColor), | ||
| 361 | + ], | ||
| 362 | + ), | ||
| 363 | + ],''' | ||
| 364 | +new_detail_tap = ''' if (card.quickActions.isNotEmpty && onAction != null) ...[ | ||
| 365 | + const SizedBox(height: 10), | ||
| 366 | + Wrap( | ||
| 367 | + spacing: 6, | ||
| 368 | + runSpacing: 6, | ||
| 369 | + children: card.quickActions.map((String action) { | ||
| 370 | + final bool isPrimary = action.contains('确认') || action.contains('好了'); | ||
| 371 | + return GestureDetector( | ||
| 372 | + onTap: () => onAction?.call(action), | ||
| 373 | + child: Container( | ||
| 374 | + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), | ||
| 375 | + decoration: BoxDecoration( | ||
| 376 | + color: isPrimary ? themeColor : themeColor.withValues(alpha: 0.10), | ||
| 377 | + borderRadius: BorderRadius.circular(16), | ||
| 378 | + border: isPrimary ? null : Border.all(color: themeColor.withValues(alpha: 0.25)), | ||
| 379 | + ), | ||
| 380 | + child: Text(action, | ||
| 381 | + style: TextStyle(fontSize: 12, fontWeight: isPrimary ? FontWeight.w600 : FontWeight.w400, | ||
| 382 | + color: isPrimary ? Colors.white : themeColor), | ||
| 383 | + ), | ||
| 384 | + ), | ||
| 385 | + ); | ||
| 386 | + }).toList(), | ||
| 387 | + ), | ||
| 388 | + ], | ||
| 389 | + if (card.kind != _CardKind.plain) ...[ | ||
| 390 | + const SizedBox(height: 8), | ||
| 391 | + Row( | ||
| 392 | + mainAxisAlignment: MainAxisAlignment.end, | ||
| 393 | + children: [ | ||
| 394 | + Text( | ||
| 395 | + '点击查看详情', | ||
| 396 | + style: TextStyle(fontSize: 10, color: themeColor), | ||
| 397 | + ), | ||
| 398 | + const SizedBox(width: 2), | ||
| 399 | + Icon(Icons.chevron_right, size: 12, color: themeColor), | ||
| 400 | + ], | ||
| 401 | + ), | ||
| 402 | + ],''' | ||
| 403 | +content = content.replace(old_detail_tap, new_detail_tap, 1) | ||
| 404 | + | ||
| 405 | +# ============================================================ | ||
| 406 | +# BLOCK D: Update _MessageBubble to pass onAction + show image | ||
| 407 | +# ============================================================ | ||
| 408 | +old_mb_card = ' child: _CardView(card: message.card!, themeColor: themeColor),' | ||
| 409 | +new_mb_card = ' child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion),' | ||
| 410 | +content = content.replace(old_mb_card, new_mb_card, 1) | ||
| 411 | + | ||
| 412 | +# Add image display in _MessageBubble (before text content) | ||
| 413 | +old_mb_content = ' _buildContent(textColor),' | ||
| 414 | +new_mb_content = ''' if (message.imagePath != null && File(message.imagePath!).existsSync()) ...[ | ||
| 415 | + Padding( | ||
| 416 | + padding: const EdgeInsets.only(bottom: 8), | ||
| 417 | + child: ClipRRect( | ||
| 418 | + borderRadius: BorderRadius.circular(10), | ||
| 419 | + child: Image.file(File(message.imagePath!), width: 180, height: 160, fit: BoxFit.cover), | ||
| 420 | + ), | ||
| 421 | + ), | ||
| 422 | + ], | ||
| 423 | + _buildContent(textColor),''' | ||
| 424 | +content = content.replace(old_mb_content, new_mb_content, 1) | ||
| 425 | + | ||
| 426 | +# Add followUps rendering in _MessageBubble after card | ||
| 427 | +old_mb_postcard = ' if (message.card != null) ...[' | ||
| 428 | +new_mb_postcard = ''' if (message.card != null) ...[ | ||
| 429 | + const SizedBox(height: 8), | ||
| 430 | + GestureDetector( | ||
| 431 | + behavior: HitTestBehavior.opaque, | ||
| 432 | + onTap: () { | ||
| 433 | + Navigator.of(context).push(MaterialPageRoute( | ||
| 434 | + builder: (_) => _AgentCardDetailPage(card: message.card!, themeColor: themeColor), | ||
| 435 | + )); | ||
| 436 | + },''' | ||
| 437 | +content = content.replace(old_mb_postcard, new_mb_postcard, 1) | ||
| 438 | + | ||
| 439 | +# Add followUps tags | ||
| 440 | +if 'if (message.card != null)' in content: | ||
| 441 | + # We already changed the structure above. Now add followUps rendering after _CardView | ||
| 442 | + old_mb_aftercard = ' child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion),' | ||
| 443 | + new_mb_aftercard = ''' child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion), | ||
| 444 | + ), | ||
| 445 | + ], | ||
| 446 | + if (message.card != null && message.status == _MessageStatus.done && message.card!.followUps.isNotEmpty) ...[ | ||
| 447 | + const SizedBox(height: 8), | ||
| 448 | + Wrap( | ||
| 449 | + spacing: 6, | ||
| 450 | + runSpacing: 6, | ||
| 451 | + children: message.card!.followUps.map((String item) { | ||
| 452 | + return GestureDetector( | ||
| 453 | + onTap: () => onSuggestion(item), | ||
| 454 | + child: Container( | ||
| 455 | + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), | ||
| 456 | + decoration: BoxDecoration( | ||
| 457 | + color: themeColor.withValues(alpha: 0.10), | ||
| 458 | + borderRadius: BorderRadius.circular(14), | ||
| 459 | + border: Border.all(color: themeColor.withValues(alpha: 0.18)), | ||
| 460 | + ), | ||
| 461 | + child: Text(item, style: TextStyle(fontSize: 12, color: themeColor)), | ||
| 462 | + ), | ||
| 463 | + ); | ||
| 464 | + }).toList(), | ||
| 465 | + ),''' | ||
| 466 | + content = content.replace(old_mb_aftercard, new_mb_aftercard, 1) | ||
| 467 | + | ||
| 468 | +with open(PATH, 'w') as f: | ||
| 469 | + f.write(content) | ||
| 470 | +print(f"SUCCESS: All changes applied. Lines: {content.count(chr(10))}") |
fix_errors.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +"""Fix the 10 remaining analysis errors.""" | ||
| 3 | +PATH = 'lib/pages/agent/agent_chat_page.dart' | ||
| 4 | + | ||
| 5 | +with open(PATH, 'r') as f: | ||
| 6 | + c = f.read() | ||
| 7 | + | ||
| 8 | +# ===== Fix 1: Remove duplicate _maybeEnterReservationFlow (my version at line ~321) ===== | ||
| 9 | +# The original at line ~630 already exists. Replace my duplicate with nothing. | ||
| 10 | +dup_maybe = ''' | ||
| 11 | + void _maybeEnterReservationFlow(String text) { | ||
| 12 | + final ReservationImageParseResult parsed = ReservationImageParser.parse(text); | ||
| 13 | + if (parsed.hasAny) _startReservationConversation(parsed); | ||
| 14 | + }''' | ||
| 15 | +if dup_maybe in c: | ||
| 16 | + c = c.replace(dup_maybe, '', 1) | ||
| 17 | + print("Removed duplicate _maybeEnterReservationFlow") | ||
| 18 | +else: | ||
| 19 | + print("WARNING: duplicate _maybeEnterReservationFlow not found") | ||
| 20 | + | ||
| 21 | +# ===== Fix 2: Remove duplicate _normalizeReservationDate (my version) ===== | ||
| 22 | +# Find the one right after _mergeReservation (line ~130 area) | ||
| 23 | +dup_norm = ''' | ||
| 24 | + ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) { | ||
| 25 | + final DateTime now = DateTime.now(); | ||
| 26 | + String date = result.date.trim(); | ||
| 27 | + if (date.isEmpty) return result; | ||
| 28 | + if (date == '今天' || date == '今日') { | ||
| 29 | + date = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; | ||
| 30 | + } else if (date == '明天' || date == '明日') { | ||
| 31 | + final DateTime t = now.add(const Duration(days: 1)); | ||
| 32 | + date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}'; | ||
| 33 | + } else if (date == '后天') { | ||
| 34 | + final DateTime t = now.add(const Duration(days: 2)); | ||
| 35 | + date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}'; | ||
| 36 | + } else if (RegExp(r'^\\d{1,2}月\\d{1,2}日$').hasMatch(date)) { | ||
| 37 | + final Match? m = RegExp(r'(\\d{1,2})月(\\d{1,2})日').firstMatch(date); | ||
| 38 | + if (m != null) { | ||
| 39 | + final int month = int.parse(m.group(1)!); | ||
| 40 | + final int day = int.parse(m.group(2)!); | ||
| 41 | + int year = now.year; | ||
| 42 | + if (DateTime(year, month, day).isBefore(DateTime(now.year, now.month, now.day))) year++; | ||
| 43 | + date = '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; | ||
| 44 | + } | ||
| 45 | + } | ||
| 46 | + return ReservationImageParseResult(name: result.name, sex: result.sex, phone: result.phone, | ||
| 47 | + party: result.party, date: date, meal: result.meal, time: result.time, | ||
| 48 | + table: result.table, remark: result.remark, rawText: result.rawText); | ||
| 49 | + }''' | ||
| 50 | + | ||
| 51 | +if dup_norm in c: | ||
| 52 | + c = c.replace(dup_norm, '', 1) | ||
| 53 | + print("Removed duplicate _normalizeReservationDate") | ||
| 54 | +else: | ||
| 55 | + print("WARNING: duplicate _normalizeReservationDate not found - trying simpler match") | ||
| 56 | + # Try to find just the signature | ||
| 57 | + idx = c.find(' ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) {') | ||
| 58 | + idx2 = c.find(' ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) {', idx + 10) | ||
| 59 | + if idx2 > idx: | ||
| 60 | + print(f" Found second at {idx2}, removing...") | ||
| 61 | + # Find end of this method | ||
| 62 | + brace_count = 0 | ||
| 63 | + end_idx = idx2 | ||
| 64 | + for i in range(idx2, len(c)): | ||
| 65 | + if c[i] == '{': brace_count += 1 | ||
| 66 | + elif c[i] == '}': | ||
| 67 | + brace_count -= 1 | ||
| 68 | + if brace_count == 0: | ||
| 69 | + end_idx = i + 1 | ||
| 70 | + break | ||
| 71 | + c = c[:idx2] + c[end_idx:] | ||
| 72 | + print(f" Removed {end_idx - idx2} chars") | ||
| 73 | + | ||
| 74 | +# ===== Fix 3: Update original _maybeEnterReservationFlow to use conversation flow ===== | ||
| 75 | +old_maybe = ''' bool _maybeEnterReservationFlow(String text) { | ||
| 76 | + final String trimmed = text.trim(); | ||
| 77 | + if (trimmed.isEmpty) return false; | ||
| 78 | + | ||
| 79 | + final ReservationImageParseResult result = | ||
| 80 | + ReservationImageParser.parse(trimmed); | ||
| 81 | + if (!_isReservationText(result)) return false; | ||
| 82 | + | ||
| 83 | + unawaited(_showReservationConfirm(_normalizeReservationDate(result))); | ||
| 84 | + return true; | ||
| 85 | + }''' | ||
| 86 | + | ||
| 87 | +new_maybe = ''' bool _maybeEnterReservationFlow(String text) { | ||
| 88 | + final String trimmed = text.trim(); | ||
| 89 | + if (trimmed.isEmpty) return false; | ||
| 90 | + | ||
| 91 | + final ReservationImageParseResult result = | ||
| 92 | + ReservationImageParser.parse(trimmed); | ||
| 93 | + if (!_isReservationText(result)) return false; | ||
| 94 | + | ||
| 95 | + _startReservationConversation(_normalizeReservationDate(result)); | ||
| 96 | + return true; | ||
| 97 | + }''' | ||
| 98 | + | ||
| 99 | +if old_maybe in c: | ||
| 100 | + c = c.replace(old_maybe, new_maybe, 1) | ||
| 101 | + print("Updated original _maybeEnterReservationFlow") | ||
| 102 | +else: | ||
| 103 | + print("WARNING: original _maybeEnterReservationFlow not found") | ||
| 104 | + | ||
| 105 | +# ===== Fix 4: Add onSuggestion parameter to _MessageBubble ===== | ||
| 106 | +old_mb = '''class _MessageBubble extends StatelessWidget { | ||
| 107 | + const _MessageBubble({required this.message, required this.themeColor}); | ||
| 108 | + | ||
| 109 | + final _ChatMessage message; | ||
| 110 | + final Color themeColor;''' | ||
| 111 | +new_mb = '''class _MessageBubble extends StatelessWidget { | ||
| 112 | + const _MessageBubble({required this.message, required this.themeColor, this.onSuggestion}); | ||
| 113 | + | ||
| 114 | + final _ChatMessage message; | ||
| 115 | + final Color themeColor; | ||
| 116 | + final ValueChanged<String>? onSuggestion;''' | ||
| 117 | +c = c.replace(old_mb, new_mb, 1) | ||
| 118 | +print("Added onSuggestion to _MessageBubble") | ||
| 119 | + | ||
| 120 | +# ===== Fix 5: Add reservationDraft to non-exhaustive switch statements ===== | ||
| 121 | +# Find the two switch(card.kind) blocks in _CardView._buildItems and _AgentCardDetailPage | ||
| 122 | +# First, find them | ||
| 123 | +for search in [' case _CardKind.orderList:', ' case _CardKind.dishRank:']: | ||
| 124 | + idx = c.find(search) | ||
| 125 | + while idx >= 0: | ||
| 126 | + # Check if this is inside a switch that doesn't have reservationDraft yet | ||
| 127 | + context = c[idx:idx+500] | ||
| 128 | + if 'reservationDraft' not in context[:200]: | ||
| 129 | + # This switch needs the case added | ||
| 130 | + insert_pos = idx | ||
| 131 | + # Find the 'default:' in this switch | ||
| 132 | + default_idx = context.find(' default:') | ||
| 133 | + if default_idx >= 0: | ||
| 134 | + insert_pos = idx + default_idx | ||
| 135 | + # Insert before default | ||
| 136 | + c = c[:insert_pos] + " case _CardKind.reservationDraft:\n return Column(\n children: card.items.map((Map<String, dynamic> item) {\n return Padding(\n padding: const EdgeInsets.symmetric(vertical: 2),\n child: Row(\n crossAxisAlignment: CrossAxisAlignment.start,\n children: [\n Text('${item['label'] ?? ''}', style: const TextStyle(fontSize: 11, color: MyColours.color_999999)),\n const SizedBox(width: 10),\n Expanded(child: Text('${item['value'] ?? ''}', textAlign: TextAlign.right, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF303030)))),\n ],\n ),\n );\n }).toList(),\n );\n" + c[insert_pos:] | ||
| 137 | + print(f"Added reservationDraft case at offset {insert_pos}") | ||
| 138 | + idx = c.find(search, idx + 1) | ||
| 139 | + | ||
| 140 | +# ===== Fix 6: Pass onSuggestion from _send to _MessageBubble ===== | ||
| 141 | +# Find where _MessageBubble is instantiated | ||
| 142 | +old_inst = ' return _MessageBubble(\n message: _messages[index],\n themeColor: themeColor,\n );' | ||
| 143 | +new_inst = ' return _MessageBubble(\n message: _messages[index],\n themeColor: themeColor,\n onSuggestion: _send,\n );' | ||
| 144 | +c = c.replace(old_inst, new_inst, 1) | ||
| 145 | +print("Wired onSuggestion to _send") | ||
| 146 | + | ||
| 147 | +# ===== Fix 7: _handleReservationClarification should handle all user messages with pending reservation ===== | ||
| 148 | +# Update _send to check and route to _handleReservationClarification | ||
| 149 | +old_send = ''' if (_pendingReservation != null) {\n _handleReservationClarification(text);\n return;\n }''' | ||
| 150 | +# Check if this routing already exists | ||
| 151 | +if '_pendingReservation' in c and '_handleReservationClarification' not in c[:2000]: | ||
| 152 | + print("Need to add routing for _handleReservationClarification") | ||
| 153 | + # Find the _send method and add the check | ||
| 154 | + send_idx = c.find(' void _send(String rawText) {') | ||
| 155 | + if send_idx >= 0: | ||
| 156 | + # Find the _maybeEnterReservationFlow check | ||
| 157 | + maybe_idx = c.find('if (_maybeEnterReservationFlow(text))', send_idx) | ||
| 158 | + if maybe_idx >= 0: | ||
| 159 | + # Insert before it | ||
| 160 | + insert_text = ''' if (_pendingReservation != null) {\n _handleReservationClarification(text);\n _inputController.clear();\n FocusScope.of(context).unfocus();\n return;\n }\n\n ''' | ||
| 161 | + c = c[:maybe_idx] + insert_text + c[maybe_idx:] | ||
| 162 | + print("Added _handleReservationClarification routing to _send") | ||
| 163 | + | ||
| 164 | +with open(PATH, 'w') as f: | ||
| 165 | + f.write(c) | ||
| 166 | + | ||
| 167 | +print(f"\nDone. Lines: {c.count(chr(10))}") |
lib/pages/agent/agent_api_client.dart
0 → 100644
| 1 | +import 'dart:async'; | ||
| 2 | + | ||
| 3 | +import 'package:dio/dio.dart'; | ||
| 4 | +import 'package:shared_preferences/shared_preferences.dart'; | ||
| 5 | + | ||
| 6 | +int _agentInt(dynamic value) { | ||
| 7 | + if (value is int) return value; | ||
| 8 | + return int.tryParse(value?.toString() ?? '') ?? 0; | ||
| 9 | +} | ||
| 10 | + | ||
| 11 | +class AgentCredentials { | ||
| 12 | + const AgentCredentials({ | ||
| 13 | + required this.uid, | ||
| 14 | + required this.sellerId, | ||
| 15 | + required this.loginToken, | ||
| 16 | + required this.deviceName, | ||
| 17 | + }); | ||
| 18 | + | ||
| 19 | + final int uid; | ||
| 20 | + final int sellerId; | ||
| 21 | + final String loginToken; | ||
| 22 | + final String deviceName; | ||
| 23 | + | ||
| 24 | + Map<String, dynamic> toJson() => <String, dynamic>{ | ||
| 25 | + 'uid': uid, | ||
| 26 | + 'seller_id': sellerId, | ||
| 27 | + 'login_token': loginToken, | ||
| 28 | + 'device_name': deviceName, | ||
| 29 | + }; | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +class AgentApiException implements Exception { | ||
| 33 | + const AgentApiException(this.statusCode, this.message); | ||
| 34 | + | ||
| 35 | + final int statusCode; | ||
| 36 | + final String message; | ||
| 37 | + | ||
| 38 | + @override | ||
| 39 | + String toString() => 'AgentApiException($statusCode, $message)'; | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +class AgentUser { | ||
| 43 | + const AgentUser({ | ||
| 44 | + required this.userType, | ||
| 45 | + required this.name, | ||
| 46 | + required this.externalUserId, | ||
| 47 | + required this.merchantId, | ||
| 48 | + required this.merchantCode, | ||
| 49 | + required this.externalRoleName, | ||
| 50 | + }); | ||
| 51 | + | ||
| 52 | + final String userType; | ||
| 53 | + final String name; | ||
| 54 | + final int externalUserId; | ||
| 55 | + final int merchantId; | ||
| 56 | + final String merchantCode; | ||
| 57 | + final String externalRoleName; | ||
| 58 | + | ||
| 59 | + factory AgentUser.fromJson(Map<String, dynamic> json) => AgentUser( | ||
| 60 | + userType: json['user_type']?.toString() ?? '', | ||
| 61 | + name: json['name']?.toString() ?? '', | ||
| 62 | + externalUserId: _agentInt(json['external_user_id']), | ||
| 63 | + merchantId: _agentInt(json['merchant_id']), | ||
| 64 | + merchantCode: json['merchant_code']?.toString() ?? '', | ||
| 65 | + externalRoleName: json['external_role_name']?.toString() ?? '', | ||
| 66 | + ); | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +class AgentLoginData { | ||
| 70 | + const AgentLoginData({ | ||
| 71 | + required this.accessToken, | ||
| 72 | + required this.tokenType, | ||
| 73 | + required this.expiresAt, | ||
| 74 | + required this.user, | ||
| 75 | + }); | ||
| 76 | + | ||
| 77 | + final String accessToken; | ||
| 78 | + final String tokenType; | ||
| 79 | + final DateTime? expiresAt; | ||
| 80 | + final AgentUser user; | ||
| 81 | + | ||
| 82 | + factory AgentLoginData.fromJson(Map<String, dynamic> json) { | ||
| 83 | + final Map<String, dynamic> data = Map<String, dynamic>.from( | ||
| 84 | + json['data'] as Map? ?? const <String, dynamic>{}); | ||
| 85 | + final String accessToken = data['access_token']?.toString() ?? ''; | ||
| 86 | + if (accessToken.isEmpty) { | ||
| 87 | + throw const AgentApiException(0, '兑换 Agent Token 失败'); | ||
| 88 | + } | ||
| 89 | + return AgentLoginData( | ||
| 90 | + accessToken: accessToken, | ||
| 91 | + tokenType: data['token_type']?.toString() ?? 'Bearer', | ||
| 92 | + expiresAt: DateTime.tryParse(data['expires_at']?.toString() ?? ''), | ||
| 93 | + user: AgentUser.fromJson( | ||
| 94 | + Map<String, dynamic>.from( | ||
| 95 | + data['user'] as Map? ?? const <String, dynamic>{}), | ||
| 96 | + ), | ||
| 97 | + ); | ||
| 98 | + } | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +class AgentConversation { | ||
| 102 | + const AgentConversation({required this.id}); | ||
| 103 | + | ||
| 104 | + final String id; | ||
| 105 | + | ||
| 106 | + factory AgentConversation.fromJson(Map<String, dynamic> json) { | ||
| 107 | + final Map<String, dynamic> data = Map<String, dynamic>.from( | ||
| 108 | + json['data'] as Map? ?? const <String, dynamic>{}); | ||
| 109 | + final String id = data['id']?.toString() ?? ''; | ||
| 110 | + if (id.isEmpty) { | ||
| 111 | + throw const AgentApiException(0, '创建会话失败'); | ||
| 112 | + } | ||
| 113 | + return AgentConversation(id: id); | ||
| 114 | + } | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +class AgentMessageData { | ||
| 118 | + const AgentMessageData({ | ||
| 119 | + required this.id, | ||
| 120 | + required this.role, | ||
| 121 | + required this.content, | ||
| 122 | + required this.status, | ||
| 123 | + required this.inputType, | ||
| 124 | + required this.snapshotIds, | ||
| 125 | + }); | ||
| 126 | + | ||
| 127 | + final String id; | ||
| 128 | + final String role; | ||
| 129 | + final String content; | ||
| 130 | + final String status; | ||
| 131 | + final String inputType; | ||
| 132 | + final List<String> snapshotIds; | ||
| 133 | + | ||
| 134 | + factory AgentMessageData.fromJson(Map<String, dynamic> json) { | ||
| 135 | + final Map<String, dynamic> data = Map<String, dynamic>.from( | ||
| 136 | + json['data'] as Map? ?? const <String, dynamic>{}); | ||
| 137 | + final List<dynamic> snapshots = data['snapshot_ids'] is List | ||
| 138 | + ? data['snapshot_ids'] as List<dynamic> | ||
| 139 | + : const <dynamic>[]; | ||
| 140 | + return AgentMessageData( | ||
| 141 | + id: data['id']?.toString() ?? '', | ||
| 142 | + role: data['role']?.toString() ?? 'assistant', | ||
| 143 | + content: data['content']?.toString() ?? '', | ||
| 144 | + status: data['status']?.toString() ?? '', | ||
| 145 | + inputType: data['input_type']?.toString() ?? 'text', | ||
| 146 | + snapshotIds: snapshots.whereType<String>().toList(), | ||
| 147 | + ); | ||
| 148 | + } | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +/// Agent 商家助手接口客户端。 | ||
| 152 | +/// | ||
| 153 | +/// 页面只依赖这个客户端访问 Agent 服务;调用方传入 App 当前登录票据, | ||
| 154 | +/// 由本客户端兑换并缓存短期 access_token,自动在 401 时重新兑换并重试一次。 | ||
| 155 | +class AgentApiClient { | ||
| 156 | + AgentApiClient._(); | ||
| 157 | + | ||
| 158 | + static final AgentApiClient instance = AgentApiClient._(); | ||
| 159 | + | ||
| 160 | + static const String baseUrl = 'https://agent.colorcun.com'; | ||
| 161 | + static const String _tokenKey = 'agent_access_token'; | ||
| 162 | + static const String _expiresKey = 'agent_token_expires_at'; | ||
| 163 | + static const String _uidKey = 'agent_bound_uid'; | ||
| 164 | + static const String _sellerKey = 'agent_bound_seller_id'; | ||
| 165 | + | ||
| 166 | + final Dio _dio = Dio( | ||
| 167 | + BaseOptions( | ||
| 168 | + baseUrl: baseUrl, | ||
| 169 | + connectTimeout: const Duration(seconds: 15), | ||
| 170 | + sendTimeout: const Duration(seconds: 30), | ||
| 171 | + receiveTimeout: const Duration(seconds: 130), | ||
| 172 | + responseType: ResponseType.json, | ||
| 173 | + ), | ||
| 174 | + ); | ||
| 175 | + | ||
| 176 | + String? _accessToken; | ||
| 177 | + DateTime? _expiresAt; | ||
| 178 | + int? _boundUid; | ||
| 179 | + int? _boundSellerId; | ||
| 180 | + AgentCredentials? _credentials; | ||
| 181 | + | ||
| 182 | + bool get _tokenUsable { | ||
| 183 | + final String? token = _accessToken; | ||
| 184 | + final DateTime? expiresAt = _expiresAt; | ||
| 185 | + if (token == null || token.isEmpty) return false; | ||
| 186 | + if (expiresAt == null) return false; | ||
| 187 | + return expiresAt | ||
| 188 | + .subtract(const Duration(seconds: 60)) | ||
| 189 | + .isAfter(DateTime.now()); | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + Future<void> prepare(AgentCredentials credentials) async { | ||
| 193 | + _credentials = credentials; | ||
| 194 | + await _loadToken(); | ||
| 195 | + if (!_tokenUsable || | ||
| 196 | + _boundUid != credentials.uid || | ||
| 197 | + _boundSellerId != credentials.sellerId) { | ||
| 198 | + await login(credentials); | ||
| 199 | + } | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + Future<AgentLoginData> login(AgentCredentials credentials) async { | ||
| 203 | + final Map<String, dynamic> response = await _request( | ||
| 204 | + 'POST', | ||
| 205 | + '/api/v1/auth/merchant/login', | ||
| 206 | + data: credentials.toJson(), | ||
| 207 | + auth: false, | ||
| 208 | + ); | ||
| 209 | + final AgentLoginData result = AgentLoginData.fromJson(response); | ||
| 210 | + _accessToken = result.accessToken; | ||
| 211 | + _expiresAt = result.expiresAt; | ||
| 212 | + _boundUid = result.user.externalUserId; | ||
| 213 | + _boundSellerId = result.user.merchantId; | ||
| 214 | + _credentials = credentials; | ||
| 215 | + await _saveToken(result); | ||
| 216 | + return result; | ||
| 217 | + } | ||
| 218 | + | ||
| 219 | + Future<AgentUser> me() async { | ||
| 220 | + final Map<String, dynamic> response = | ||
| 221 | + await _authorized('GET', '/api/v1/me'); | ||
| 222 | + final Map<String, dynamic> data = Map<String, dynamic>.from( | ||
| 223 | + response['data'] as Map? ?? const <String, dynamic>{}); | ||
| 224 | + return AgentUser.fromJson(data); | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + Future<AgentConversation> createConversation( | ||
| 228 | + {CancelToken? cancelToken}) async { | ||
| 229 | + final Map<String, dynamic> response = await _authorized( | ||
| 230 | + 'POST', | ||
| 231 | + '/api/v1/conversations', | ||
| 232 | + data: const <String, dynamic>{}, | ||
| 233 | + cancelToken: cancelToken, | ||
| 234 | + ); | ||
| 235 | + return AgentConversation.fromJson(response); | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + Future<AgentMessageData> sendMessage({ | ||
| 239 | + required String conversationId, | ||
| 240 | + required String content, | ||
| 241 | + required String inputType, | ||
| 242 | + required String clientMessageId, | ||
| 243 | + CancelToken? cancelToken, | ||
| 244 | + }) async { | ||
| 245 | + final Map<String, dynamic> response = await _authorized( | ||
| 246 | + 'POST', | ||
| 247 | + '/api/v1/conversations/$conversationId/messages', | ||
| 248 | + data: <String, dynamic>{ | ||
| 249 | + 'content': content, | ||
| 250 | + 'input_type': inputType, | ||
| 251 | + 'client_message_id': clientMessageId, | ||
| 252 | + }, | ||
| 253 | + cancelToken: cancelToken, | ||
| 254 | + ); | ||
| 255 | + return AgentMessageData.fromJson(response); | ||
| 256 | + } | ||
| 257 | + | ||
| 258 | + Future<String> transcribeAudio({ | ||
| 259 | + required String filePath, | ||
| 260 | + required String fileName, | ||
| 261 | + required String mimeType, | ||
| 262 | + CancelToken? cancelToken, | ||
| 263 | + }) async { | ||
| 264 | + final List<String> parts = mimeType.split('/'); | ||
| 265 | + final DioMediaType mediaType = parts.length == 2 | ||
| 266 | + ? DioMediaType(parts[0], parts[1]) | ||
| 267 | + : DioMediaType('application', 'octet-stream'); | ||
| 268 | + final FormData formData = FormData.fromMap(<String, dynamic>{ | ||
| 269 | + 'audio': await MultipartFile.fromFile( | ||
| 270 | + filePath, | ||
| 271 | + filename: fileName, | ||
| 272 | + contentType: mediaType, | ||
| 273 | + ), | ||
| 274 | + }); | ||
| 275 | + final Map<String, dynamic> response = await _authorized( | ||
| 276 | + 'POST', | ||
| 277 | + '/api/v1/audio/transcriptions', | ||
| 278 | + data: formData, | ||
| 279 | + cancelToken: cancelToken, | ||
| 280 | + ); | ||
| 281 | + final Map<String, dynamic> data = Map<String, dynamic>.from( | ||
| 282 | + response['data'] as Map? ?? const <String, dynamic>{}); | ||
| 283 | + return data['text']?.toString().trim() ?? ''; | ||
| 284 | + } | ||
| 285 | + | ||
| 286 | + Future<void> submitFeedback({ | ||
| 287 | + required String messageId, | ||
| 288 | + required int rating, | ||
| 289 | + String reason = '', | ||
| 290 | + String comment = '', | ||
| 291 | + }) async { | ||
| 292 | + await _authorized( | ||
| 293 | + 'PUT', | ||
| 294 | + '/api/v1/messages/$messageId/feedback', | ||
| 295 | + data: <String, dynamic>{ | ||
| 296 | + 'rating': rating, | ||
| 297 | + 'reason': reason, | ||
| 298 | + 'comment': comment, | ||
| 299 | + }, | ||
| 300 | + ); | ||
| 301 | + } | ||
| 302 | + | ||
| 303 | + Future<void> deleteConversation(String conversationId) async { | ||
| 304 | + await _authorized('DELETE', '/api/v1/conversations/$conversationId'); | ||
| 305 | + } | ||
| 306 | + | ||
| 307 | + /// 退出登录时调用:尝试通知 Agent 服务并立即清除本地 Token。 | ||
| 308 | + Future<void> logout() async { | ||
| 309 | + final String? token = _accessToken; | ||
| 310 | + _credentials = null; | ||
| 311 | + await _clearToken(); | ||
| 312 | + if (token == null || token.isEmpty) return; | ||
| 313 | + | ||
| 314 | + _accessToken = token; | ||
| 315 | + try { | ||
| 316 | + await _request('DELETE', '/api/v1/auth/merchant/token', auth: true); | ||
| 317 | + } catch (_) { | ||
| 318 | + // 网络失败时本地已清除,不再阻塞退出登录。 | ||
| 319 | + } finally { | ||
| 320 | + _accessToken = null; | ||
| 321 | + } | ||
| 322 | + } | ||
| 323 | + | ||
| 324 | + Future<Map<String, dynamic>> _authorized( | ||
| 325 | + String method, | ||
| 326 | + String path, { | ||
| 327 | + Object? data, | ||
| 328 | + Options? options, | ||
| 329 | + CancelToken? cancelToken, | ||
| 330 | + }) async { | ||
| 331 | + if (_credentials == null) { | ||
| 332 | + throw const AgentApiException(0, '未获取到 App 登录信息,请重新登录'); | ||
| 333 | + } | ||
| 334 | + await _ensureToken(); | ||
| 335 | + try { | ||
| 336 | + return await _request( | ||
| 337 | + method, | ||
| 338 | + path, | ||
| 339 | + data: data, | ||
| 340 | + options: options, | ||
| 341 | + cancelToken: cancelToken, | ||
| 342 | + auth: true, | ||
| 343 | + ); | ||
| 344 | + } on AgentApiException catch (error) { | ||
| 345 | + if (error.statusCode != 401) rethrow; | ||
| 346 | + await _clearToken(); | ||
| 347 | + await login(_credentials!); | ||
| 348 | + return _request( | ||
| 349 | + method, | ||
| 350 | + path, | ||
| 351 | + data: data, | ||
| 352 | + options: options, | ||
| 353 | + cancelToken: cancelToken, | ||
| 354 | + auth: true, | ||
| 355 | + ); | ||
| 356 | + } | ||
| 357 | + } | ||
| 358 | + | ||
| 359 | + Future<void> _ensureToken() async { | ||
| 360 | + await _loadToken(); | ||
| 361 | + final AgentCredentials? credentials = _credentials; | ||
| 362 | + if (credentials == null || | ||
| 363 | + !_tokenUsable || | ||
| 364 | + _boundUid != credentials.uid || | ||
| 365 | + _boundSellerId != credentials.sellerId) { | ||
| 366 | + if (credentials == null) { | ||
| 367 | + throw const AgentApiException(0, '未获取到 App 登录信息,请重新登录'); | ||
| 368 | + } | ||
| 369 | + await login(credentials); | ||
| 370 | + } | ||
| 371 | + } | ||
| 372 | + | ||
| 373 | + Future<Map<String, dynamic>> _request( | ||
| 374 | + String method, | ||
| 375 | + String path, { | ||
| 376 | + Object? data, | ||
| 377 | + Options? options, | ||
| 378 | + CancelToken? cancelToken, | ||
| 379 | + bool auth = true, | ||
| 380 | + }) async { | ||
| 381 | + final Options requestOptions = options ?? Options(); | ||
| 382 | + requestOptions.method = method; | ||
| 383 | + requestOptions.headers ??= <String, dynamic>{}; | ||
| 384 | + requestOptions.headers!['Accept'] = 'application/json'; | ||
| 385 | + if (auth) { | ||
| 386 | + requestOptions.headers!['Authorization'] = 'Bearer ${_accessToken ?? ''}'; | ||
| 387 | + } | ||
| 388 | + if (data is Map) { | ||
| 389 | + requestOptions.headers!['Content-Type'] = 'application/json'; | ||
| 390 | + } | ||
| 391 | + | ||
| 392 | + try { | ||
| 393 | + final Response<dynamic> response = await _dio.request<dynamic>( | ||
| 394 | + path, | ||
| 395 | + data: data, | ||
| 396 | + options: requestOptions, | ||
| 397 | + cancelToken: cancelToken, | ||
| 398 | + ); | ||
| 399 | + return _toMap(response.data); | ||
| 400 | + } on DioException catch (error) { | ||
| 401 | + if (error.type == DioExceptionType.cancel) rethrow; | ||
| 402 | + final int status = error.response?.statusCode ?? 0; | ||
| 403 | + throw AgentApiException( | ||
| 404 | + status, _messageFor(status, error.response?.data)); | ||
| 405 | + } | ||
| 406 | + } | ||
| 407 | + | ||
| 408 | + Map<String, dynamic> _toMap(dynamic data) { | ||
| 409 | + if (data == null) return <String, dynamic>{}; | ||
| 410 | + if (data is Map) return Map<String, dynamic>.from(data); | ||
| 411 | + return <String, dynamic>{'data': data}; | ||
| 412 | + } | ||
| 413 | + | ||
| 414 | + String _messageFor(int statusCode, dynamic data) { | ||
| 415 | + switch (statusCode) { | ||
| 416 | + case 400: | ||
| 417 | + return '请求参数有误,请检查后重试'; | ||
| 418 | + case 401: | ||
| 419 | + return 'App 登录状态无效或已过期,请重新登录'; | ||
| 420 | + case 403: | ||
| 421 | + return '当前商家账号暂无权限,请联系管理员'; | ||
| 422 | + case 404: | ||
| 423 | + return '会话或消息不存在,请刷新后重试'; | ||
| 424 | + case 422: | ||
| 425 | + return '输入内容不符合要求,请修改后重试'; | ||
| 426 | + case 429: | ||
| 427 | + return '操作太频繁,请稍后再试'; | ||
| 428 | + case 502: | ||
| 429 | + return '业务数据服务繁忙,请稍后重试'; | ||
| 430 | + case 503: | ||
| 431 | + return 'AI 服务暂时不可用,请稍后重试'; | ||
| 432 | + default: | ||
| 433 | + if (data is Map) { | ||
| 434 | + final dynamic message = data['message']; | ||
| 435 | + if (message is String && message.trim().isNotEmpty) { | ||
| 436 | + return message.trim(); | ||
| 437 | + } | ||
| 438 | + final dynamic error = data['error']; | ||
| 439 | + if (error is String && error.trim().isNotEmpty) return error.trim(); | ||
| 440 | + if (error is Map && error['message'] is String) { | ||
| 441 | + return error['message'].toString(); | ||
| 442 | + } | ||
| 443 | + } | ||
| 444 | + return '网络请求失败,请稍后重试'; | ||
| 445 | + } | ||
| 446 | + } | ||
| 447 | + | ||
| 448 | + Future<void> _loadToken() async { | ||
| 449 | + final SharedPreferences prefs = await SharedPreferences.getInstance(); | ||
| 450 | + _accessToken = prefs.getString(_tokenKey); | ||
| 451 | + _expiresAt = DateTime.tryParse(prefs.getString(_expiresKey) ?? ''); | ||
| 452 | + _boundUid = prefs.getInt(_uidKey); | ||
| 453 | + _boundSellerId = prefs.getInt(_sellerKey); | ||
| 454 | + } | ||
| 455 | + | ||
| 456 | + Future<void> _saveToken(AgentLoginData data) async { | ||
| 457 | + final SharedPreferences prefs = await SharedPreferences.getInstance(); | ||
| 458 | + await prefs.setString(_tokenKey, data.accessToken); | ||
| 459 | + if (data.expiresAt != null) { | ||
| 460 | + await prefs.setString(_expiresKey, data.expiresAt!.toIso8601String()); | ||
| 461 | + } | ||
| 462 | + await prefs.setInt(_uidKey, data.user.externalUserId); | ||
| 463 | + await prefs.setInt(_sellerKey, data.user.merchantId); | ||
| 464 | + } | ||
| 465 | + | ||
| 466 | + Future<void> _clearToken() async { | ||
| 467 | + _accessToken = null; | ||
| 468 | + _expiresAt = null; | ||
| 469 | + _boundUid = null; | ||
| 470 | + _boundSellerId = null; | ||
| 471 | + final SharedPreferences prefs = await SharedPreferences.getInstance(); | ||
| 472 | + await prefs.remove(_tokenKey); | ||
| 473 | + await prefs.remove(_expiresKey); | ||
| 474 | + await prefs.remove(_uidKey); | ||
| 475 | + await prefs.remove(_sellerKey); | ||
| 476 | + } | ||
| 477 | +} |
| 1 | import 'dart:async'; | 1 | import 'dart:async'; |
| 2 | import 'dart:io'; | 2 | import 'dart:io'; |
| 3 | +import 'package:dio/dio.dart'; | ||
| 3 | import 'package:flutter/foundation.dart'; | 4 | import 'package:flutter/foundation.dart'; |
| 4 | import 'package:flutter/material.dart'; | 5 | import 'package:flutter/material.dart'; |
| 5 | import 'package:flutter/services.dart'; | 6 | import 'package:flutter/services.dart'; |
| 6 | import 'package:image_picker/image_picker.dart'; | 7 | import 'package:image_picker/image_picker.dart'; |
| 8 | +import 'package:mime/mime.dart'; | ||
| 7 | import 'package:one_key/app.dart'; | 9 | import 'package:one_key/app.dart'; |
| 8 | import 'package:one_key/models/notebook_detail.dart'; | 10 | import 'package:one_key/models/notebook_detail.dart'; |
| 11 | +import 'package:one_key/models/user.dart'; | ||
| 12 | +import 'package:one_key/pages/agent/agent_api_client.dart'; | ||
| 9 | import 'package:one_key/pages/agent/agent_chat_store.dart'; | 13 | import 'package:one_key/pages/agent/agent_chat_store.dart'; |
| 10 | import 'package:one_key/pages/agent/qwen_ocr_client.dart'; | 14 | import 'package:one_key/pages/agent/qwen_ocr_client.dart'; |
| 11 | import 'package:one_key/pages/agent/reservation_image_parser.dart'; | 15 | import 'package:one_key/pages/agent/reservation_image_parser.dart'; |
| 16 | +import 'package:path_provider/path_provider.dart'; | ||
| 12 | import 'package:one_key/pages/agent/shared_reservation_receiver.dart'; | 17 | import 'package:one_key/pages/agent/shared_reservation_receiver.dart'; |
| 13 | import 'package:one_key/pages/agent/paraformer_asr_client.dart'; | 18 | import 'package:one_key/pages/agent/paraformer_asr_client.dart'; |
| 19 | +import 'package:permission_handler/permission_handler.dart'; | ||
| 14 | import 'package:one_key/provider/user_model.dart'; | 20 | import 'package:one_key/provider/user_model.dart'; |
| 15 | import 'package:one_key/res/colors.dart'; | 21 | import 'package:one_key/res/colors.dart'; |
| 16 | import 'package:one_key/route/navigator_util.dart'; | 22 | import 'package:one_key/route/navigator_util.dart'; |
| 17 | import 'package:one_key/utils/utils.dart'; | 23 | import 'package:one_key/utils/utils.dart'; |
| 18 | import 'package:provider/provider.dart'; | 24 | import 'package:provider/provider.dart'; |
| 25 | +import 'package:record/record.dart'; | ||
| 19 | import 'package:speech_to_text/speech_recognition_result.dart'; | 26 | import 'package:speech_to_text/speech_recognition_result.dart'; |
| 20 | import 'package:speech_to_text/speech_to_text.dart'; | 27 | import 'package:speech_to_text/speech_to_text.dart'; |
| 21 | import 'package:whisper_cpp_flutter_plus/whisper_cpp_flutter_plus.dart'; | 28 | import 'package:whisper_cpp_flutter_plus/whisper_cpp_flutter_plus.dart'; |
| @@ -25,18 +32,30 @@ enum _Role { user, agent } | @@ -25,18 +32,30 @@ enum _Role { user, agent } | ||
| 25 | 32 | ||
| 26 | enum _MessageStatus { sending, streaming, done, error } | 33 | enum _MessageStatus { sending, streaming, done, error } |
| 27 | 34 | ||
| 28 | -enum _CardKind { kpi, tableList, bookingList, orderList, dishRank, plain } | 35 | +enum _CardKind { |
| 36 | + kpi, | ||
| 37 | + tableList, | ||
| 38 | + bookingList, | ||
| 39 | + orderList, | ||
| 40 | + dishRank, | ||
| 41 | + plain, | ||
| 42 | + reservationDraft | ||
| 43 | +} | ||
| 29 | 44 | ||
| 30 | class _AgentCard { | 45 | class _AgentCard { |
| 31 | const _AgentCard({ | 46 | const _AgentCard({ |
| 32 | required this.kind, | 47 | required this.kind, |
| 33 | required this.title, | 48 | required this.title, |
| 34 | required this.items, | 49 | required this.items, |
| 50 | + this.followUps = const <String>[], | ||
| 51 | + this.quickActions = const <String>[], | ||
| 35 | }); | 52 | }); |
| 36 | 53 | ||
| 37 | final _CardKind kind; | 54 | final _CardKind kind; |
| 38 | final String title; | 55 | final String title; |
| 39 | final List<Map<String, dynamic>> items; | 56 | final List<Map<String, dynamic>> items; |
| 57 | + final List<String> followUps; | ||
| 58 | + final List<String> quickActions; | ||
| 40 | } | 59 | } |
| 41 | 60 | ||
| 42 | class _ChatMessage { | 61 | class _ChatMessage { |
| @@ -45,6 +64,9 @@ class _ChatMessage { | @@ -45,6 +64,9 @@ class _ChatMessage { | ||
| 45 | required this.text, | 64 | required this.text, |
| 46 | this.card, | 65 | this.card, |
| 47 | this.status = _MessageStatus.done, | 66 | this.status = _MessageStatus.done, |
| 67 | + this.imagePath, | ||
| 68 | + this.clientMessageId, | ||
| 69 | + this.inputType = 'text', | ||
| 48 | String? id, | 70 | String? id, |
| 49 | DateTime? ts, | 71 | DateTime? ts, |
| 50 | }) : id = id ?? '${DateTime.now().microsecondsSinceEpoch}_${role.name}', | 72 | }) : id = id ?? '${DateTime.now().microsecondsSinceEpoch}_${role.name}', |
| @@ -56,6 +78,9 @@ class _ChatMessage { | @@ -56,6 +78,9 @@ class _ChatMessage { | ||
| 56 | _AgentCard? card; | 78 | _AgentCard? card; |
| 57 | _MessageStatus status; | 79 | _MessageStatus status; |
| 58 | DateTime ts; | 80 | DateTime ts; |
| 81 | + final String? imagePath; | ||
| 82 | + final String? clientMessageId; | ||
| 83 | + final String inputType; | ||
| 59 | } | 84 | } |
| 60 | 85 | ||
| 61 | class _MockReply { | 86 | class _MockReply { |
| @@ -88,6 +113,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -88,6 +113,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 88 | final List<_ChatMessage> _messages = []; | 113 | final List<_ChatMessage> _messages = []; |
| 89 | final SpeechToText _speech = SpeechToText(); | 114 | final SpeechToText _speech = SpeechToText(); |
| 90 | final AgentChatStore _chatStore = AgentChatStore.instance; | 115 | final AgentChatStore _chatStore = AgentChatStore.instance; |
| 116 | + final AgentApiClient _agentApi = AgentApiClient.instance; | ||
| 91 | 117 | ||
| 92 | Timer? _typingTimer; | 118 | Timer? _typingTimer; |
| 93 | Timer? _voiceTimer; | 119 | Timer? _voiceTimer; |
| @@ -97,6 +123,472 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -97,6 +123,472 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 97 | bool _historyLoading = true; | 123 | bool _historyLoading = true; |
| 98 | bool _usingNativeSpeech = false; | 124 | bool _usingNativeSpeech = false; |
| 99 | bool _usingWhisper = false; | 125 | bool _usingWhisper = false; |
| 126 | + bool _nextMessageIsVoice = false; | ||
| 127 | + bool _agentAuthExpiredHandling = false; | ||
| 128 | + String? _agentConversationId; | ||
| 129 | + CancelToken? _agentCancelToken; | ||
| 130 | + AudioRecorder? _agentAudioRecorder; | ||
| 131 | + String? _agentAudioPath; | ||
| 132 | + | ||
| 133 | + /// ---- 预订会话式录入 ---- | ||
| 134 | + ReservationImageParseResult? _pendingReservation; | ||
| 135 | + bool _reservationAwaitingConfirm = false; | ||
| 136 | + | ||
| 137 | + bool _reservationRequiredComplete(ReservationImageParseResult result) { | ||
| 138 | + return result.name.isNotEmpty && | ||
| 139 | + result.phone.isNotEmpty && | ||
| 140 | + result.party.isNotEmpty && | ||
| 141 | + result.date.isNotEmpty; | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + ReservationImageParseResult _mergeReservation( | ||
| 145 | + ReservationImageParseResult old, ReservationImageParseResult next) { | ||
| 146 | + return ReservationImageParseResult( | ||
| 147 | + name: next.name.isNotEmpty ? next.name : old.name, | ||
| 148 | + sex: next.sex.isNotEmpty ? next.sex : old.sex, | ||
| 149 | + phone: next.phone.isNotEmpty ? next.phone : old.phone, | ||
| 150 | + party: next.party.isNotEmpty ? next.party : old.party, | ||
| 151 | + date: next.date.isNotEmpty ? next.date : old.date, | ||
| 152 | + meal: next.meal.isNotEmpty ? next.meal : old.meal, | ||
| 153 | + time: next.time.isNotEmpty ? next.time : old.time, | ||
| 154 | + table: next.table.isNotEmpty ? next.table : old.table, | ||
| 155 | + remark: next.remark.isNotEmpty ? next.remark : old.remark, | ||
| 156 | + rawText: next.rawText.isNotEmpty ? next.rawText : old.rawText, | ||
| 157 | + ); | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + String _reservationIntro(ReservationImageParseResult result) { | ||
| 161 | + final List<String> parts = <String>[]; | ||
| 162 | + if (result.name.isNotEmpty) parts.add('👤 预订人:${result.name}'); | ||
| 163 | + if (result.phone.isNotEmpty) parts.add('📱 电话:${result.phone}'); | ||
| 164 | + if (result.party.isNotEmpty) parts.add('👥 人数:${result.party}人'); | ||
| 165 | + if (result.date.isNotEmpty) parts.add('📅 日期:${result.date}'); | ||
| 166 | + if (result.meal.isNotEmpty) parts.add('🍽 时段:${result.meal}'); | ||
| 167 | + if (result.time.isNotEmpty) parts.add('⏰ 到店时间:${result.time}'); | ||
| 168 | + if (result.table.isNotEmpty) parts.add('🪑 桌台:${result.table}'); | ||
| 169 | + if (parts.isEmpty) { | ||
| 170 | + return '我没有从信息中识别到完整的预订字段,咱们一起补全吧。'; | ||
| 171 | + } | ||
| 172 | + return '📋 识别到以下信息:\n${parts.join('\n')}'; | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + String _reservationSummaryText(ReservationImageParseResult result) { | ||
| 176 | + final List<String> parts = <String>[]; | ||
| 177 | + if (result.name.isNotEmpty) parts.add('姓名: ${result.name}'); | ||
| 178 | + if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}'); | ||
| 179 | + if (result.party.isNotEmpty) parts.add('人数: ${result.party}人'); | ||
| 180 | + if (result.date.isNotEmpty) parts.add('日期: ${result.date}'); | ||
| 181 | + if (result.meal.isNotEmpty) parts.add('时段: ${result.meal}'); | ||
| 182 | + if (result.time.isNotEmpty) parts.add('到店时间: ${result.time}'); | ||
| 183 | + if (result.table.isNotEmpty) parts.add('桌台: ${result.table}'); | ||
| 184 | + return parts.join('\n'); | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + String _nextReservationQuestion(ReservationImageParseResult result) { | ||
| 188 | + final List<String> missing = <String>[]; | ||
| 189 | + if (result.name.isEmpty) missing.add('预订人姓名'); | ||
| 190 | + if (result.phone.isEmpty) missing.add('手机号'); | ||
| 191 | + if (result.party.isEmpty) missing.add('用餐人数'); | ||
| 192 | + if (result.date.isEmpty) missing.add('预订日期'); | ||
| 193 | + if (missing.isEmpty) return '信息已经齐全啦,确认一下就可以录入。'; | ||
| 194 | + return '还缺 ${missing.join('、')},直接回复补充即可,\n例如"王先生 13800138000 8人 明晚"。'; | ||
| 195 | + } | ||
| 196 | + | ||
| 197 | + _AgentCard _reservationDraftCard(ReservationImageParseResult result, | ||
| 198 | + {List<String> quickActions = const <String>[]}) { | ||
| 199 | + final List<String> actions = quickActions.isNotEmpty | ||
| 200 | + ? quickActions | ||
| 201 | + : const <String>['确认录入', '好了,没问题', '修改信息', '取消']; | ||
| 202 | + // 字段不全时不带快捷按钮,但带追问词条引导补全 | ||
| 203 | + final bool complete = _reservationRequiredComplete(result); | ||
| 204 | + return _AgentCard( | ||
| 205 | + kind: _CardKind.reservationDraft, | ||
| 206 | + title: '预订信息草稿', | ||
| 207 | + followUps: complete ? const <String>[] : const <String>['我直接说', '重新识别'], | ||
| 208 | + items: <Map<String, dynamic>>[ | ||
| 209 | + {'label': '姓名', 'value': result.name.isEmpty ? '待补充' : result.name}, | ||
| 210 | + {'label': '电话', 'value': result.phone.isEmpty ? '待补充' : result.phone}, | ||
| 211 | + { | ||
| 212 | + 'label': '人数', | ||
| 213 | + 'value': result.party.isEmpty ? '待补充' : '${result.party}人' | ||
| 214 | + }, | ||
| 215 | + {'label': '日期', 'value': result.date.isEmpty ? '待补充' : result.date}, | ||
| 216 | + if (result.meal.isNotEmpty) {'label': '时段', 'value': result.meal}, | ||
| 217 | + if (result.time.isNotEmpty) {'label': '到店时间', 'value': result.time}, | ||
| 218 | + if (result.table.isNotEmpty) {'label': '桌台', 'value': result.table}, | ||
| 219 | + ], | ||
| 220 | + quickActions: _reservationAwaitingConfirm ? actions : const <String>[], | ||
| 221 | + ); | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + bool _isReservationConfirmText(String text) { | ||
| 225 | + final String t = text.trim(); | ||
| 226 | + // 精确匹配所有快捷按钮文字 + 常见口语确认 | ||
| 227 | + if (RegExp( | ||
| 228 | + r'^(确认|确认录入|好了|好了,没问题|好的|可以|可以录入|没问题|没问题了|没问题,录入吧|录入|行|对|ok|yes)$', | ||
| 229 | + caseSensitive: false) | ||
| 230 | + .hasMatch(t)) return true; | ||
| 231 | + if (t.contains('确认') && | ||
| 232 | + !t.contains('改') && | ||
| 233 | + !t.contains('换') && | ||
| 234 | + !t.contains('不是') && | ||
| 235 | + !t.contains('错了')) return true; | ||
| 236 | + if ((t.contains('好了') || t.contains('没问题')) && | ||
| 237 | + !t.contains('改') && | ||
| 238 | + !t.contains('不是')) return true; | ||
| 239 | + return false; | ||
| 240 | + } | ||
| 241 | + | ||
| 242 | + bool _isModificationIntent(String text) { | ||
| 243 | + final String t = text.trim(); | ||
| 244 | + return t == '修改信息' || | ||
| 245 | + t == '修改' || | ||
| 246 | + t == '改一下' || | ||
| 247 | + t == '改' || | ||
| 248 | + t == '我要修改' || | ||
| 249 | + t == '需要修改' || | ||
| 250 | + t == '修改一下' || | ||
| 251 | + t == '改改' || | ||
| 252 | + t == '要改' || | ||
| 253 | + t == '调整一下' || | ||
| 254 | + t == '调整'; | ||
| 255 | + } | ||
| 256 | + | ||
| 257 | + /// 从自然语言修改指令中提取字段(如"把手机号改成138xxxx")。 | ||
| 258 | + ReservationImageParseResult _parseModificationText(String text) { | ||
| 259 | + String name = ''; | ||
| 260 | + String phone = ''; | ||
| 261 | + String party = ''; | ||
| 262 | + String date = ''; | ||
| 263 | + String meal = ''; | ||
| 264 | + String time = ''; | ||
| 265 | + String table = ''; | ||
| 266 | + | ||
| 267 | + // 模式:把/将 XXX 改成/改为/换成 YYY | ||
| 268 | + final RegExp modPattern = RegExp( | ||
| 269 | + r'(?:把|将)?\s*(\S+?)\s*(?:改成|改为|换成|改成是|换为|修改为|改成是)\s*(\S+)', | ||
| 270 | + ); | ||
| 271 | + for (final Match m in modPattern.allMatches(text)) { | ||
| 272 | + final String field = m.group(1) ?? ''; | ||
| 273 | + final String value = m.group(2) ?? ''; | ||
| 274 | + | ||
| 275 | + if (field.contains('名') || field.contains('姓') || field == '名字') { | ||
| 276 | + name = value; | ||
| 277 | + } else if (field.contains('手机') || | ||
| 278 | + field.contains('电话') || | ||
| 279 | + field.contains('号')) { | ||
| 280 | + phone = RegExp(r'1[3-9]\d{9}').firstMatch(value)?.group(0) ?? value; | ||
| 281 | + } else if (field.contains('人') || field.contains('位')) { | ||
| 282 | + party = RegExp(r'\d+').firstMatch(value)?.group(0) ?? ''; | ||
| 283 | + } else if (field.contains('日') || field.contains('期')) { | ||
| 284 | + date = value; | ||
| 285 | + } else if (field.contains('时') || | ||
| 286 | + field.contains('餐') || | ||
| 287 | + field.contains('饭')) { | ||
| 288 | + meal = value; | ||
| 289 | + } else if (field.contains('桌') || | ||
| 290 | + field.contains('台') || | ||
| 291 | + field.contains('包间')) { | ||
| 292 | + table = value; | ||
| 293 | + } | ||
| 294 | + } | ||
| 295 | + | ||
| 296 | + // 回退:直接尝试标准解析 | ||
| 297 | + if (name.isEmpty && phone.isEmpty && party.isEmpty && date.isEmpty) { | ||
| 298 | + return ReservationImageParser.parse(text); | ||
| 299 | + } | ||
| 300 | + | ||
| 301 | + return ReservationImageParseResult( | ||
| 302 | + name: name, | ||
| 303 | + sex: '', | ||
| 304 | + phone: phone, | ||
| 305 | + party: party, | ||
| 306 | + date: date, | ||
| 307 | + meal: meal, | ||
| 308 | + time: time, | ||
| 309 | + table: table, | ||
| 310 | + remark: '', | ||
| 311 | + rawText: text, | ||
| 312 | + ); | ||
| 313 | + } | ||
| 314 | + | ||
| 315 | + String _describeChanges( | ||
| 316 | + ReservationImageParseResult before, ReservationImageParseResult after) { | ||
| 317 | + final List<String> fields = <String>[]; | ||
| 318 | + if (after.name.isNotEmpty && after.name != before.name) fields.add('姓名'); | ||
| 319 | + if (after.phone.isNotEmpty && after.phone != before.phone) | ||
| 320 | + fields.add('手机号'); | ||
| 321 | + if (after.party.isNotEmpty && after.party != before.party) fields.add('人数'); | ||
| 322 | + if (after.date.isNotEmpty && after.date != before.date) fields.add('日期'); | ||
| 323 | + if (after.meal.isNotEmpty && after.meal != before.meal) fields.add('时段'); | ||
| 324 | + if (after.time.isNotEmpty && after.time != before.time) fields.add('到店时间'); | ||
| 325 | + if (after.table.isNotEmpty && after.table != before.table) fields.add('桌台'); | ||
| 326 | + if (fields.isEmpty) return ''; | ||
| 327 | + return fields.join('、'); | ||
| 328 | + } | ||
| 329 | + | ||
| 330 | + Map<String, String> _reservationToFields(ReservationImageParseResult result) { | ||
| 331 | + return <String, String>{ | ||
| 332 | + 'name': result.name, | ||
| 333 | + 'sex': result.sex, | ||
| 334 | + 'phone': result.phone, | ||
| 335 | + 'party': result.party, | ||
| 336 | + 'date': result.date, | ||
| 337 | + 'meal': result.meal, | ||
| 338 | + 'time': result.time, | ||
| 339 | + 'table': result.table, | ||
| 340 | + 'remark': result.remark, | ||
| 341 | + }; | ||
| 342 | + } | ||
| 343 | + | ||
| 344 | + /// 订单确认后的摘要文本(管家风格)。 | ||
| 345 | + String _reservationOrderSummary( | ||
| 346 | + ReservationImageParseResult result, Map<String, String> fields) { | ||
| 347 | + final StringBuffer sb = StringBuffer(); | ||
| 348 | + final String name = fields['name']?.trim() ?? ''; | ||
| 349 | + final String sex = fields['sex']?.trim() ?? ''; | ||
| 350 | + final String phone = fields['phone']?.trim() ?? ''; | ||
| 351 | + final String party = fields['party']?.trim() ?? ''; | ||
| 352 | + final String date = fields['date']?.trim() ?? ''; | ||
| 353 | + final String meal = fields['meal']?.trim() ?? ''; | ||
| 354 | + final String time = fields['time']?.trim() ?? ''; | ||
| 355 | + final String table = fields['table']?.trim() ?? ''; | ||
| 356 | + | ||
| 357 | + // 第一行:核心信息一行展示 | ||
| 358 | + final List<String> meta = <String>[]; | ||
| 359 | + if (date.isNotEmpty) meta.add(date); | ||
| 360 | + if (meal.isNotEmpty) meta.add(meal); | ||
| 361 | + if (table.isNotEmpty) meta.add('$table桌'); | ||
| 362 | + sb.writeln(meta.isNotEmpty ? '📌 ${meta.join(' · ')}' : '📌 已确认'); | ||
| 363 | + sb.writeln('━━━━━━━━━━━━━━'); | ||
| 364 | + | ||
| 365 | + // 第二行:人物信息 | ||
| 366 | + final String displayName = sex.isNotEmpty ? '$name$sex' : name; | ||
| 367 | + sb.write('👤 $displayName'); | ||
| 368 | + if (party.isNotEmpty) sb.write(' · $party人'); | ||
| 369 | + if (phone.isNotEmpty) sb.write(' · $phone'); | ||
| 370 | + sb.writeln(); | ||
| 371 | + | ||
| 372 | + // 附加信息 | ||
| 373 | + if (time.isNotEmpty) { | ||
| 374 | + sb.writeln('⏰ 到店时间:$time'); | ||
| 375 | + } | ||
| 376 | + final String remark = fields['remark']?.trim() ?? ''; | ||
| 377 | + if (remark.isNotEmpty) { | ||
| 378 | + sb.writeln('📝 备注:$remark'); | ||
| 379 | + } | ||
| 380 | + | ||
| 381 | + return sb.toString().trim(); | ||
| 382 | + } | ||
| 383 | + | ||
| 384 | + /// 确认后的订单卡片(管家风格,带追问)。 | ||
| 385 | + _AgentCard _reservationOrderCard( | ||
| 386 | + ReservationImageParseResult result, Map<String, String> fields) { | ||
| 387 | + final String date = fields['date']?.trim() ?? ''; | ||
| 388 | + final String meal = fields['meal']?.trim() ?? ''; | ||
| 389 | + final String title = date.isNotEmpty | ||
| 390 | + ? (meal.isNotEmpty ? '预订确认 · $date $meal' : '预订确认 · $date') | ||
| 391 | + : '预订确认 ✅'; | ||
| 392 | + return _AgentCard( | ||
| 393 | + kind: _CardKind.plain, | ||
| 394 | + title: title, | ||
| 395 | + items: <Map<String, dynamic>>[ | ||
| 396 | + { | ||
| 397 | + 'label': '预订人', | ||
| 398 | + 'value': | ||
| 399 | + '${fields['name']?.trim() ?? "-"}${(fields['sex']?.trim() ?? "").isNotEmpty ? "(${fields['sex']!.trim()})" : ""}', | ||
| 400 | + }, | ||
| 401 | + {'label': '电话', 'value': fields['phone']?.trim() ?? "-"}, | ||
| 402 | + {'label': '人数', 'value': '${fields['party']?.trim() ?? "-"}人'}, | ||
| 403 | + if ((fields['time']?.trim() ?? '').isNotEmpty) | ||
| 404 | + {'label': '到店时间', 'value': fields['time']!.trim()}, | ||
| 405 | + if ((fields['table']?.trim() ?? '').isNotEmpty) | ||
| 406 | + {'label': '桌台', 'value': '${fields['table']!.trim()}桌'}, | ||
| 407 | + if ((fields['remark']?.trim() ?? '').isNotEmpty) | ||
| 408 | + {'label': '备注', 'value': fields['remark']!.trim()}, | ||
| 409 | + ], | ||
| 410 | + followUps: const <String>['今晚还有空桌吗?', '查看今日预订', '查订单'], | ||
| 411 | + ); | ||
| 412 | + } | ||
| 413 | + | ||
| 414 | + Future<void> _startReservationConversation(ReservationImageParseResult result, | ||
| 415 | + {String? imagePath}) async { | ||
| 416 | + if (!mounted) return; | ||
| 417 | + _pendingReservation = _normalizeReservationDate(result); | ||
| 418 | + _reservationAwaitingConfirm = | ||
| 419 | + _reservationRequiredComplete(_pendingReservation!); | ||
| 420 | + final String intro = _reservationIntro(_pendingReservation!); | ||
| 421 | + final String replyText = _reservationAwaitingConfirm | ||
| 422 | + ? '$intro\n\n信息齐全,确认无误即可录入。' | ||
| 423 | + : '$intro\n\n⚠️ ${_nextReservationQuestion(_pendingReservation!)}'; | ||
| 424 | + setState(() { | ||
| 425 | + _messages.add(_ChatMessage( | ||
| 426 | + role: _Role.user, | ||
| 427 | + text: imagePath != null && imagePath.isNotEmpty | ||
| 428 | + ? '我发来一张预订截图' | ||
| 429 | + : '语音录入预订信息', | ||
| 430 | + imagePath: imagePath, | ||
| 431 | + )); | ||
| 432 | + _messages.add(_ChatMessage( | ||
| 433 | + role: _Role.agent, | ||
| 434 | + text: replyText, | ||
| 435 | + card: _reservationDraftCard(_pendingReservation!), | ||
| 436 | + )); | ||
| 437 | + }); | ||
| 438 | + _scrollToBottom(); | ||
| 439 | + _persist(); | ||
| 440 | + } | ||
| 441 | + | ||
| 442 | + Future<void> _handleReservationClarification(String text) async { | ||
| 443 | + final ReservationImageParseResult? current = _pendingReservation; | ||
| 444 | + if (current == null || !mounted) return; | ||
| 445 | + if (text.contains('取消') || text.contains('算了') || text.contains('不用')) { | ||
| 446 | + _pendingReservation = null; | ||
| 447 | + _reservationAwaitingConfirm = false; | ||
| 448 | + setState(() { | ||
| 449 | + _messages.add(_ChatMessage(role: _Role.user, text: text)); | ||
| 450 | + _messages.add(_ChatMessage( | ||
| 451 | + role: _Role.agent, | ||
| 452 | + text: '好的,已取消这次预订录入。还想查什么随时问我~', | ||
| 453 | + card: _AgentCard( | ||
| 454 | + kind: _CardKind.plain, | ||
| 455 | + title: '已取消录入', | ||
| 456 | + items: const <Map<String, dynamic>>[], | ||
| 457 | + followUps: const <String>['今晚还有空桌吗?', '查看今日预订', '重新录入预订'], | ||
| 458 | + ), | ||
| 459 | + )); | ||
| 460 | + }); | ||
| 461 | + _scrollToBottom(); | ||
| 462 | + _persist(); | ||
| 463 | + return; | ||
| 464 | + } | ||
| 465 | + setState(() { | ||
| 466 | + _messages.add(_ChatMessage(role: _Role.user, text: text)); | ||
| 467 | + }); | ||
| 468 | + _scrollToBottom(); | ||
| 469 | + _persist(); | ||
| 470 | + | ||
| 471 | + if (_reservationAwaitingConfirm) { | ||
| 472 | + if (_isReservationConfirmText(text)) { | ||
| 473 | + final Map<String, String> fields = _reservationToFields(current); | ||
| 474 | + final String summary = _reservationOrderSummary(current, fields); | ||
| 475 | + _pendingReservation = null; | ||
| 476 | + _reservationAwaitingConfirm = false; | ||
| 477 | + | ||
| 478 | + // 根据多轮补全次数调整语气 | ||
| 479 | + final int totalRounds = _messages | ||
| 480 | + .where((m) => m.role == _Role.user && m.text.contains('预订')) | ||
| 481 | + .length; | ||
| 482 | + final String tone = | ||
| 483 | + totalRounds <= 2 ? '搞定!预订已录入 ✅' : '不容易,终于凑齐了!已帮你录入 ✅'; | ||
| 484 | + | ||
| 485 | + setState(() { | ||
| 486 | + _messages.add(_ChatMessage( | ||
| 487 | + role: _Role.agent, | ||
| 488 | + text: '$tone\n\n$summary\n━━━━━━━━━━━━━━\n' | ||
| 489 | + '还需要我帮你看看什么?', | ||
| 490 | + card: _reservationOrderCard(current, fields), | ||
| 491 | + )); | ||
| 492 | + }); | ||
| 493 | + _scrollToBottom(); | ||
| 494 | + _persist(); | ||
| 495 | + return; | ||
| 496 | + } | ||
| 497 | + if (_isModificationIntent(text)) { | ||
| 498 | + setState(() { | ||
| 499 | + _messages.add(_ChatMessage( | ||
| 500 | + role: _Role.agent, | ||
| 501 | + text: '好的,请问要修改哪一项?直接告诉我就行,比如"手机号改成138xxxx""姓名改成张三"。', | ||
| 502 | + card: _reservationDraftCard(current))); | ||
| 503 | + }); | ||
| 504 | + _scrollToBottom(); | ||
| 505 | + _persist(); | ||
| 506 | + return; | ||
| 507 | + } | ||
| 508 | + // 优先用标准解析器,解析失败时尝试自然语言"改成"模式 | ||
| 509 | + ReservationImageParseResult parsed = ReservationImageParser.parse(text); | ||
| 510 | + if (!parsed.hasAny) { | ||
| 511 | + parsed = _parseModificationText(text); | ||
| 512 | + } | ||
| 513 | + final bool hasUpdate = parsed.hasAny; | ||
| 514 | + final ReservationImageParseResult merged = | ||
| 515 | + _normalizeReservationDate(_mergeReservation(current, parsed)); | ||
| 516 | + _pendingReservation = merged; | ||
| 517 | + _reservationAwaitingConfirm = _reservationRequiredComplete(merged); | ||
| 518 | + final String changed = hasUpdate ? _describeChanges(current, parsed) : ''; | ||
| 519 | + setState(() { | ||
| 520 | + _messages.add(_ChatMessage( | ||
| 521 | + role: _Role.agent, | ||
| 522 | + text: hasUpdate | ||
| 523 | + ? (_reservationAwaitingConfirm | ||
| 524 | + ? '已修改$changed,确认无误回复"确认"即可录入。' | ||
| 525 | + : '已修改$changed,${_nextReservationQuestion(merged)}') | ||
| 526 | + : '没有识别到具体要修改的内容,能再说一遍吗?\n比如"手机号改成138xxxx0000""姓名改成张三"。', | ||
| 527 | + card: _reservationDraftCard(merged))); | ||
| 528 | + }); | ||
| 529 | + _scrollToBottom(); | ||
| 530 | + _persist(); | ||
| 531 | + return; | ||
| 532 | + } | ||
| 533 | + | ||
| 534 | + final ReservationImageParseResult parsed = | ||
| 535 | + ReservationImageParser.parse(text); | ||
| 536 | + final ReservationImageParseResult merged = | ||
| 537 | + _normalizeReservationDate(_mergeReservation(current, parsed)); | ||
| 538 | + if (_reservationRequiredComplete(merged)) { | ||
| 539 | + _pendingReservation = merged; | ||
| 540 | + _reservationAwaitingConfirm = true; | ||
| 541 | + setState(() { | ||
| 542 | + _messages.add(_ChatMessage( | ||
| 543 | + role: _Role.agent, | ||
| 544 | + text: | ||
| 545 | + '信息已经齐全:\n${_reservationSummaryText(merged)}\n\n确认无误就回复"确认",我直接帮你录入;要修改哪一项也直接告诉我。', | ||
| 546 | + card: _reservationDraftCard(merged))); | ||
| 547 | + }); | ||
| 548 | + _scrollToBottom(); | ||
| 549 | + _persist(); | ||
| 550 | + return; | ||
| 551 | + } | ||
| 552 | + _pendingReservation = merged; | ||
| 553 | + _reservationAwaitingConfirm = false; | ||
| 554 | + setState(() { | ||
| 555 | + _messages.add(_ChatMessage( | ||
| 556 | + role: _Role.agent, | ||
| 557 | + text: _nextReservationQuestion(merged), | ||
| 558 | + card: _reservationDraftCard(merged))); | ||
| 559 | + }); | ||
| 560 | + _scrollToBottom(); | ||
| 561 | + _persist(); | ||
| 562 | + } | ||
| 563 | + | ||
| 564 | + List<String> _followUpsFor(_CardKind kind) { | ||
| 565 | + switch (kind) { | ||
| 566 | + case _CardKind.kpi: | ||
| 567 | + return const <String>['昨天呢?', '近7天统计', '同比环比']; | ||
| 568 | + case _CardKind.tableList: | ||
| 569 | + return const <String>['今晚空桌呢?', '4人可用桌位', '桌位状态']; | ||
| 570 | + case _CardKind.bookingList: | ||
| 571 | + return const <String>['未确认的预订', '预订详情']; | ||
| 572 | + case _CardKind.orderList: | ||
| 573 | + return const <String>['支付状态', '指定桌位订单']; | ||
| 574 | + case _CardKind.dishRank: | ||
| 575 | + return const <String>['销量排行', '销售额排行', '滞销菜品']; | ||
| 576 | + case _CardKind.reservationDraft: | ||
| 577 | + case _CardKind.plain: | ||
| 578 | + return const <String>[]; | ||
| 579 | + } | ||
| 580 | + } | ||
| 581 | + | ||
| 582 | + _AgentCard _withFollowUps(_AgentCard card) { | ||
| 583 | + return _AgentCard( | ||
| 584 | + kind: card.kind, | ||
| 585 | + title: card.title, | ||
| 586 | + items: card.items, | ||
| 587 | + followUps: _followUpsFor(card.kind), | ||
| 588 | + quickActions: card.quickActions, | ||
| 589 | + ); | ||
| 590 | + } | ||
| 591 | + | ||
| 100 | bool _usingCloudAsr = false; | 592 | bool _usingCloudAsr = false; |
| 101 | bool _whisperModelReady = false; | 593 | bool _whisperModelReady = false; |
| 102 | bool _voicePressActive = false; | 594 | bool _voicePressActive = false; |
| @@ -132,16 +624,99 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -132,16 +624,99 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 132 | return model.user?.sellerid ?? 'unknown'; | 624 | return model.user?.sellerid ?? 'unknown'; |
| 133 | } | 625 | } |
| 134 | 626 | ||
| 627 | + AgentCredentials? _agentCredentials() { | ||
| 628 | + final User? user = Provider.of<UserModel>(context, listen: false).user; | ||
| 629 | + if (user == null) return null; | ||
| 630 | + final int? uid = int.tryParse(user.openid ?? ''); | ||
| 631 | + final int? sellerId = int.tryParse(user.sellerid ?? ''); | ||
| 632 | + final String loginToken = user.token ?? ''; | ||
| 633 | + if (uid == null || sellerId == null || loginToken.isEmpty) return null; | ||
| 634 | + | ||
| 635 | + final String deviceName = switch (defaultTargetPlatform) { | ||
| 636 | + TargetPlatform.android => 'android-main', | ||
| 637 | + TargetPlatform.iOS => 'ios-main', | ||
| 638 | + _ => 'desktop-main', | ||
| 639 | + }; | ||
| 640 | + return AgentCredentials( | ||
| 641 | + uid: uid, | ||
| 642 | + sellerId: sellerId, | ||
| 643 | + loginToken: loginToken, | ||
| 644 | + deviceName: deviceName, | ||
| 645 | + ); | ||
| 646 | + } | ||
| 647 | + | ||
| 648 | + Future<void> _prepareAgentSession() async { | ||
| 649 | + try { | ||
| 650 | + final AgentCredentials? credentials = _agentCredentials(); | ||
| 651 | + if (credentials == null) return; | ||
| 652 | + await _agentApi.prepare(credentials); | ||
| 653 | + await _ensureAgentConversation(); | ||
| 654 | + } on AgentApiException catch (error) { | ||
| 655 | + if (error.statusCode == 401) { | ||
| 656 | + _handleAgentAuthExpired(); | ||
| 657 | + } else { | ||
| 658 | + debugPrint('Agent session prepare skipped: $error'); | ||
| 659 | + } | ||
| 660 | + } catch (error) { | ||
| 661 | + debugPrint('Agent session prepare skipped: $error'); | ||
| 662 | + } | ||
| 663 | + } | ||
| 664 | + | ||
| 665 | + void _handleAgentAuthExpired() { | ||
| 666 | + if (!mounted || _agentAuthExpiredHandling) return; | ||
| 667 | + _agentAuthExpiredHandling = true; | ||
| 668 | + _agentConversationId = null; | ||
| 669 | + ScaffoldMessenger.of(context).showSnackBar( | ||
| 670 | + const SnackBar(content: Text('App 登录已过期,请重新登录')), | ||
| 671 | + ); | ||
| 672 | + WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| 673 | + if (mounted) NavigatorUtil.goLoginPage(context); | ||
| 674 | + }); | ||
| 675 | + } | ||
| 676 | + | ||
| 677 | + Future<String> _ensureAgentConversation({CancelToken? cancelToken}) async { | ||
| 678 | + final String? current = _agentConversationId; | ||
| 679 | + if (current != null && current.isNotEmpty) return current; | ||
| 680 | + final AgentConversation conversation = | ||
| 681 | + await _agentApi.createConversation(cancelToken: cancelToken); | ||
| 682 | + _agentConversationId = conversation.id; | ||
| 683 | + return conversation.id; | ||
| 684 | + } | ||
| 685 | + | ||
| 686 | + Future<void> _disposeAgentAudioRecorder( | ||
| 687 | + AudioRecorder? recorder, { | ||
| 688 | + bool cancelFirst = false, | ||
| 689 | + }) async { | ||
| 690 | + if (recorder == null) return; | ||
| 691 | + if (cancelFirst) { | ||
| 692 | + try { | ||
| 693 | + await recorder.cancel(); | ||
| 694 | + } catch (_) {} | ||
| 695 | + } | ||
| 696 | + try { | ||
| 697 | + await recorder.dispose(); | ||
| 698 | + } catch (_) {} | ||
| 699 | + } | ||
| 700 | + | ||
| 701 | + void _deleteAgentVoiceFile(String? path) { | ||
| 702 | + if (path == null || path.isEmpty) return; | ||
| 703 | + final File file = File(path); | ||
| 704 | + if (file.existsSync()) { | ||
| 705 | + try { | ||
| 706 | + file.deleteSync(); | ||
| 707 | + } catch (_) {} | ||
| 708 | + } | ||
| 709 | + } | ||
| 710 | + | ||
| 135 | @override | 711 | @override |
| 136 | void initState() { | 712 | void initState() { |
| 137 | super.initState(); | 713 | super.initState(); |
| 138 | Utils.changeStatusColor(color: MyColours.color_199095); | 714 | Utils.changeStatusColor(color: MyColours.color_199095); |
| 139 | if (!_supportsWhisper) { | 715 | if (!_supportsWhisper) { |
| 140 | _initSpeech(); | 716 | _initSpeech(); |
| 141 | - } else { | ||
| 142 | - unawaited(_prewarmWhisperEngine()); | ||
| 143 | } | 717 | } |
| 144 | _loadHistory(); | 718 | _loadHistory(); |
| 719 | + unawaited(_prepareAgentSession()); | ||
| 145 | _initSharedReservationListener(); | 720 | _initSharedReservationListener(); |
| 146 | } | 721 | } |
| 147 | 722 | ||
| @@ -159,6 +734,14 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -159,6 +734,14 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 159 | } | 734 | } |
| 160 | unawaited(_whisperTask?.cancel()); | 735 | unawaited(_whisperTask?.cancel()); |
| 161 | unawaited(_cloudAsrSession?.cancel()); | 736 | unawaited(_cloudAsrSession?.cancel()); |
| 737 | + _agentCancelToken?.cancel(); | ||
| 738 | + _deleteAgentVoiceFile(_agentAudioPath); | ||
| 739 | + unawaited(_disposeAgentAudioRecorder( | ||
| 740 | + _agentAudioRecorder, | ||
| 741 | + cancelFirst: true, | ||
| 742 | + )); | ||
| 743 | + _agentAudioRecorder = null; | ||
| 744 | + _agentAudioPath = null; | ||
| 162 | try { | 745 | try { |
| 163 | _whisperEngine?.dispose(); | 746 | _whisperEngine?.dispose(); |
| 164 | } catch (_) {} | 747 | } catch (_) {} |
| @@ -258,6 +841,12 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -258,6 +841,12 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 258 | title: json['title']?.toString() ?? '', | 841 | title: json['title']?.toString() ?? '', |
| 259 | items: (json['items'] as List? ?? const <dynamic>[]) | 842 | items: (json['items'] as List? ?? const <dynamic>[]) |
| 260 | .cast<Map<String, dynamic>>(), | 843 | .cast<Map<String, dynamic>>(), |
| 844 | + followUps: (json['followUps'] as List? ?? const <dynamic>[]) | ||
| 845 | + .whereType<String>() | ||
| 846 | + .toList(), | ||
| 847 | + quickActions: (json['quickActions'] as List? ?? const <dynamic>[]) | ||
| 848 | + .whereType<String>() | ||
| 849 | + .toList(), | ||
| 261 | ); | 850 | ); |
| 262 | } | 851 | } |
| 263 | 852 | ||
| @@ -273,6 +862,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -273,6 +862,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 273 | return _CardKind.orderList; | 862 | return _CardKind.orderList; |
| 274 | case 'dish_rank': | 863 | case 'dish_rank': |
| 275 | return _CardKind.dishRank; | 864 | return _CardKind.dishRank; |
| 865 | + case 'reservation_draft': | ||
| 866 | + return _CardKind.reservationDraft; | ||
| 276 | default: | 867 | default: |
| 277 | return _CardKind.plain; | 868 | return _CardKind.plain; |
| 278 | } | 869 | } |
| @@ -290,6 +881,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -290,6 +881,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 290 | return 'order_list'; | 881 | return 'order_list'; |
| 291 | case _CardKind.dishRank: | 882 | case _CardKind.dishRank: |
| 292 | return 'dish_rank'; | 883 | return 'dish_rank'; |
| 884 | + case _CardKind.reservationDraft: | ||
| 885 | + return 'reservation_draft'; | ||
| 293 | case _CardKind.plain: | 886 | case _CardKind.plain: |
| 294 | return 'plain'; | 887 | return 'plain'; |
| 295 | } | 888 | } |
| @@ -336,25 +929,48 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -336,25 +929,48 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 336 | }); | 929 | }); |
| 337 | } | 930 | } |
| 338 | 931 | ||
| 339 | - void _send(String rawText) { | 932 | + void _send(String rawText, {String inputType = 'text'}) { |
| 340 | final String text = rawText.trim(); | 933 | final String text = rawText.trim(); |
| 341 | if (text.isEmpty || _streaming) return; | 934 | if (text.isEmpty || _streaming) return; |
| 342 | 935 | ||
| 936 | + if (_pendingReservation != null) { | ||
| 937 | + _handleReservationClarification(text); | ||
| 938 | + _inputController.clear(); | ||
| 939 | + FocusScope.of(context).unfocus(); | ||
| 940 | + return; | ||
| 941 | + } | ||
| 942 | + | ||
| 343 | if (_maybeEnterReservationFlow(text)) { | 943 | if (_maybeEnterReservationFlow(text)) { |
| 344 | _inputController.clear(); | 944 | _inputController.clear(); |
| 345 | FocusScope.of(context).unfocus(); | 945 | FocusScope.of(context).unfocus(); |
| 346 | return; | 946 | return; |
| 347 | } | 947 | } |
| 348 | 948 | ||
| 949 | + _sendAgentMessage(text, inputType: inputType); | ||
| 950 | + } | ||
| 951 | + | ||
| 952 | + void _sendAgentMessage( | ||
| 953 | + String text, { | ||
| 954 | + String inputType = 'text', | ||
| 955 | + String? clientMessageId, | ||
| 956 | + }) { | ||
| 957 | + final String messageId = clientMessageId ?? _newClientMessageId(); | ||
| 958 | + final _ChatMessage userMessage = _ChatMessage( | ||
| 959 | + role: _Role.user, | ||
| 960 | + text: text, | ||
| 961 | + clientMessageId: messageId, | ||
| 962 | + inputType: inputType, | ||
| 963 | + ); | ||
| 964 | + final _ChatMessage agentMessage = _ChatMessage( | ||
| 965 | + role: _Role.agent, | ||
| 966 | + text: '', | ||
| 967 | + status: _MessageStatus.sending, | ||
| 968 | + ); | ||
| 969 | + | ||
| 349 | setState(() { | 970 | setState(() { |
| 350 | - _messages.add(_ChatMessage(role: _Role.user, text: text)); | ||
| 351 | - _messages.add( | ||
| 352 | - _ChatMessage( | ||
| 353 | - role: _Role.agent, | ||
| 354 | - text: '', | ||
| 355 | - status: _MessageStatus.sending, | ||
| 356 | - ), | ||
| 357 | - ); | 971 | + _messages |
| 972 | + ..add(userMessage) | ||
| 973 | + ..add(agentMessage); | ||
| 358 | _streaming = true; | 974 | _streaming = true; |
| 359 | _stopRequested = false; | 975 | _stopRequested = false; |
| 360 | _voiceStage = _VoiceStage.idle; | 976 | _voiceStage = _VoiceStage.idle; |
| @@ -365,11 +981,148 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -365,11 +981,148 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 365 | _scrollToBottom(); | 981 | _scrollToBottom(); |
| 366 | _persist(); | 982 | _persist(); |
| 367 | 983 | ||
| 368 | - final _MockReply reply = _mockReply(text); | ||
| 369 | - Future.delayed(const Duration(milliseconds: 450), () { | ||
| 370 | - if (!mounted) return; | ||
| 371 | - _startTypewriter(reply); | 984 | + unawaited(_requestAgentReply( |
| 985 | + text: text, | ||
| 986 | + inputType: inputType, | ||
| 987 | + clientMessageId: messageId, | ||
| 988 | + agentMessage: agentMessage, | ||
| 989 | + )); | ||
| 990 | + } | ||
| 991 | + | ||
| 992 | + String _newClientMessageId() { | ||
| 993 | + return 'app-msg-${DateTime.now().microsecondsSinceEpoch}-${_messages.length}'; | ||
| 994 | + } | ||
| 995 | + | ||
| 996 | + Future<void> _requestAgentReply({ | ||
| 997 | + required String text, | ||
| 998 | + required String inputType, | ||
| 999 | + required String clientMessageId, | ||
| 1000 | + required _ChatMessage agentMessage, | ||
| 1001 | + }) async { | ||
| 1002 | + final CancelToken cancelToken = CancelToken(); | ||
| 1003 | + _agentCancelToken = cancelToken; | ||
| 1004 | + | ||
| 1005 | + try { | ||
| 1006 | + final AgentCredentials? credentials = _agentCredentials(); | ||
| 1007 | + if (credentials == null) { | ||
| 1008 | + throw const AgentApiException(0, '未获取到登录信息,请重新登录'); | ||
| 1009 | + } | ||
| 1010 | + await _agentApi.prepare(credentials); | ||
| 1011 | + | ||
| 1012 | + String conversationId = await _ensureAgentConversation( | ||
| 1013 | + cancelToken: cancelToken, | ||
| 1014 | + ); | ||
| 1015 | + AgentMessageData data; | ||
| 1016 | + try { | ||
| 1017 | + data = await _agentApi.sendMessage( | ||
| 1018 | + conversationId: conversationId, | ||
| 1019 | + content: text, | ||
| 1020 | + inputType: inputType, | ||
| 1021 | + clientMessageId: clientMessageId, | ||
| 1022 | + cancelToken: cancelToken, | ||
| 1023 | + ); | ||
| 1024 | + } on AgentApiException catch (error) { | ||
| 1025 | + if (error.statusCode != 404) rethrow; | ||
| 1026 | + _agentConversationId = null; | ||
| 1027 | + conversationId = await _ensureAgentConversation( | ||
| 1028 | + cancelToken: cancelToken, | ||
| 1029 | + ); | ||
| 1030 | + data = await _agentApi.sendMessage( | ||
| 1031 | + conversationId: conversationId, | ||
| 1032 | + content: text, | ||
| 1033 | + inputType: inputType, | ||
| 1034 | + clientMessageId: clientMessageId, | ||
| 1035 | + cancelToken: cancelToken, | ||
| 1036 | + ); | ||
| 1037 | + } | ||
| 1038 | + | ||
| 1039 | + final String reply = data.content.trim(); | ||
| 1040 | + if (reply.isEmpty) { | ||
| 1041 | + throw const AgentApiException(0, 'AI 没有返回内容,请重试'); | ||
| 1042 | + } | ||
| 1043 | + if (!mounted || cancelToken.isCancelled) return; | ||
| 1044 | + _startTypewriter(_MockReply(reply)); | ||
| 1045 | + } on AgentApiException catch (error) { | ||
| 1046 | + if (error.statusCode == 401) { | ||
| 1047 | + _handleAgentAuthExpired(); | ||
| 1048 | + } | ||
| 1049 | + _finishAgentRequestError(agentMessage, error.message); | ||
| 1050 | + } on DioException catch (error) { | ||
| 1051 | + if (cancelToken.isCancelled || error.type == DioExceptionType.cancel) { | ||
| 1052 | + _finishAgentRequestStopped(agentMessage); | ||
| 1053 | + } else { | ||
| 1054 | + _finishAgentRequestError(agentMessage, '网络请求失败,请稍后重试'); | ||
| 1055 | + } | ||
| 1056 | + } catch (_) { | ||
| 1057 | + _finishAgentRequestError(agentMessage, '请求失败,请稍后重试'); | ||
| 1058 | + } finally { | ||
| 1059 | + if (identical(_agentCancelToken, cancelToken)) { | ||
| 1060 | + _agentCancelToken = null; | ||
| 1061 | + } | ||
| 1062 | + } | ||
| 1063 | + } | ||
| 1064 | + | ||
| 1065 | + void _finishAgentRequestError(_ChatMessage agentMessage, String message) { | ||
| 1066 | + if (!mounted) return; | ||
| 1067 | + setState(() { | ||
| 1068 | + agentMessage.text = message.isEmpty ? '请求失败,请稍后重试' : message; | ||
| 1069 | + agentMessage.card = null; | ||
| 1070 | + agentMessage.status = _MessageStatus.error; | ||
| 1071 | + _streaming = false; | ||
| 1072 | + _stopRequested = false; | ||
| 372 | }); | 1073 | }); |
| 1074 | + _scrollToBottom(); | ||
| 1075 | + _persist(); | ||
| 1076 | + } | ||
| 1077 | + | ||
| 1078 | + void _finishAgentRequestStopped(_ChatMessage agentMessage) { | ||
| 1079 | + if (!mounted) return; | ||
| 1080 | + setState(() { | ||
| 1081 | + final String current = agentMessage.text.trimRight(); | ||
| 1082 | + agentMessage.text = current.isEmpty ? '已停止。' : '$current (已停止)'; | ||
| 1083 | + agentMessage.status = _MessageStatus.done; | ||
| 1084 | + _streaming = false; | ||
| 1085 | + _stopRequested = false; | ||
| 1086 | + }); | ||
| 1087 | + _scrollToBottom(); | ||
| 1088 | + _persist(); | ||
| 1089 | + } | ||
| 1090 | + | ||
| 1091 | + void _retryAgentMessage(int agentIndex) { | ||
| 1092 | + if (_streaming || agentIndex <= 0 || agentIndex >= _messages.length) { | ||
| 1093 | + return; | ||
| 1094 | + } | ||
| 1095 | + final _ChatMessage agentMessage = _messages[agentIndex]; | ||
| 1096 | + if (agentMessage.role != _Role.agent || | ||
| 1097 | + agentMessage.status != _MessageStatus.error) { | ||
| 1098 | + return; | ||
| 1099 | + } | ||
| 1100 | + | ||
| 1101 | + _ChatMessage? userMessage; | ||
| 1102 | + for (int index = agentIndex - 1; index >= 0; index--) { | ||
| 1103 | + if (_messages[index].role == _Role.user) { | ||
| 1104 | + userMessage = _messages[index]; | ||
| 1105 | + break; | ||
| 1106 | + } | ||
| 1107 | + } | ||
| 1108 | + if (userMessage == null) return; | ||
| 1109 | + | ||
| 1110 | + final String messageId = | ||
| 1111 | + userMessage.clientMessageId ?? _newClientMessageId(); | ||
| 1112 | + setState(() { | ||
| 1113 | + agentMessage.text = ''; | ||
| 1114 | + agentMessage.card = null; | ||
| 1115 | + agentMessage.status = _MessageStatus.sending; | ||
| 1116 | + _streaming = true; | ||
| 1117 | + _stopRequested = false; | ||
| 1118 | + }); | ||
| 1119 | + _persist(); | ||
| 1120 | + unawaited(_requestAgentReply( | ||
| 1121 | + text: userMessage.text, | ||
| 1122 | + inputType: userMessage.inputType, | ||
| 1123 | + clientMessageId: messageId, | ||
| 1124 | + agentMessage: agentMessage, | ||
| 1125 | + )); | ||
| 373 | } | 1126 | } |
| 374 | 1127 | ||
| 375 | bool _maybeEnterReservationFlow(String text) { | 1128 | bool _maybeEnterReservationFlow(String text) { |
| @@ -380,7 +1133,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -380,7 +1133,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 380 | ReservationImageParser.parse(trimmed); | 1133 | ReservationImageParser.parse(trimmed); |
| 381 | if (!_isReservationText(result)) return false; | 1134 | if (!_isReservationText(result)) return false; |
| 382 | 1135 | ||
| 383 | - unawaited(_showReservationConfirm(_normalizeReservationDate(result))); | 1136 | + _startReservationConversation(_normalizeReservationDate(result)); |
| 384 | return true; | 1137 | return true; |
| 385 | } | 1138 | } |
| 386 | 1139 | ||
| @@ -443,6 +1196,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -443,6 +1196,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 443 | 1196 | ||
| 444 | void _stop() { | 1197 | void _stop() { |
| 445 | _stopRequested = true; | 1198 | _stopRequested = true; |
| 1199 | + final CancelToken? cancelToken = _agentCancelToken; | ||
| 1200 | + if (cancelToken != null && !cancelToken.isCancelled) { | ||
| 1201 | + cancelToken.cancel('用户停止'); | ||
| 1202 | + } | ||
| 446 | } | 1203 | } |
| 447 | 1204 | ||
| 448 | bool _containsAny(String q, List<String> keywords) { | 1205 | bool _containsAny(String q, List<String> keywords) { |
| @@ -853,7 +1610,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -853,7 +1610,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 853 | _voiceTimer?.cancel(); | 1610 | _voiceTimer?.cancel(); |
| 854 | 1611 | ||
| 855 | if (_supportsWhisper) { | 1612 | if (_supportsWhisper) { |
| 856 | - await _startCloudVoice(requestId); | 1613 | + await _startAgentVoice(requestId); |
| 857 | return; | 1614 | return; |
| 858 | } | 1615 | } |
| 859 | 1616 | ||
| @@ -861,6 +1618,151 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -861,6 +1618,151 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 861 | await _startLegacyVoice(); | 1618 | await _startLegacyVoice(); |
| 862 | } | 1619 | } |
| 863 | 1620 | ||
| 1621 | + /// 使用 Agent 服务录音转写;录音文件只发送到 Agent 服务,不再调用百炼 ASR。 | ||
| 1622 | + Future<void> _startAgentVoice(int requestId) async { | ||
| 1623 | + setState(() { | ||
| 1624 | + _voiceStage = _VoiceStage.preparing; | ||
| 1625 | + _voiceSeconds = 0; | ||
| 1626 | + _recognizedText = ''; | ||
| 1627 | + _voiceHint = '正在准备录音…'; | ||
| 1628 | + }); | ||
| 1629 | + | ||
| 1630 | + final AudioRecorder recorder = AudioRecorder(); | ||
| 1631 | + _agentAudioRecorder = recorder; | ||
| 1632 | + try { | ||
| 1633 | + if (!await recorder.hasPermission()) { | ||
| 1634 | + final PermissionStatus status = await Permission.microphone.request(); | ||
| 1635 | + if (!status.isGranted) { | ||
| 1636 | + throw const AgentApiException(0, '未获得麦克风权限'); | ||
| 1637 | + } | ||
| 1638 | + } | ||
| 1639 | + | ||
| 1640 | + final Directory directory = await getTemporaryDirectory(); | ||
| 1641 | + final String path = | ||
| 1642 | + '${directory.path}/agent_voice_${DateTime.now().microsecondsSinceEpoch}.wav'; | ||
| 1643 | + await recorder.start( | ||
| 1644 | + const RecordConfig( | ||
| 1645 | + encoder: AudioEncoder.wav, | ||
| 1646 | + sampleRate: 16000, | ||
| 1647 | + numChannels: 1, | ||
| 1648 | + ), | ||
| 1649 | + path: path, | ||
| 1650 | + ); | ||
| 1651 | + _agentAudioPath = path; | ||
| 1652 | + | ||
| 1653 | + if (!mounted || requestId != _voiceRequestId) { | ||
| 1654 | + await _cancelAgentVoice(dispose: true); | ||
| 1655 | + return; | ||
| 1656 | + } | ||
| 1657 | + if (!_voicePressActive) { | ||
| 1658 | + await _cancelAgentVoice(dispose: true); | ||
| 1659 | + return; | ||
| 1660 | + } | ||
| 1661 | + | ||
| 1662 | + setState(() { | ||
| 1663 | + _voiceStage = _VoiceStage.recording; | ||
| 1664 | + _voiceSeconds = 0; | ||
| 1665 | + _recognizedText = ''; | ||
| 1666 | + _voiceHint = ''; | ||
| 1667 | + }); | ||
| 1668 | + _voiceTimer = Timer.periodic(const Duration(seconds: 1), (_) { | ||
| 1669 | + if (!mounted) return; | ||
| 1670 | + setState(() => _voiceSeconds++); | ||
| 1671 | + }); | ||
| 1672 | + } catch (error) { | ||
| 1673 | + debugPrint('Agent voice recording start failed: $error'); | ||
| 1674 | + await _cancelAgentVoice(dispose: true); | ||
| 1675 | + if (mounted) { | ||
| 1676 | + _resetVoiceAfterError('无法启动录音,请检查麦克风权限'); | ||
| 1677 | + } | ||
| 1678 | + } | ||
| 1679 | + } | ||
| 1680 | + | ||
| 1681 | + Future<void> _stopAgentVoice(int requestId) async { | ||
| 1682 | + _voiceTimer?.cancel(); | ||
| 1683 | + final AudioRecorder? recorder = _agentAudioRecorder; | ||
| 1684 | + final String? recordedPath = _agentAudioPath; | ||
| 1685 | + _agentAudioRecorder = null; | ||
| 1686 | + _agentAudioPath = null; | ||
| 1687 | + if (recorder == null) return; | ||
| 1688 | + | ||
| 1689 | + String? path; | ||
| 1690 | + try { | ||
| 1691 | + path = await recorder.stop(); | ||
| 1692 | + } catch (_) {} | ||
| 1693 | + await _disposeAgentAudioRecorder(recorder); | ||
| 1694 | + | ||
| 1695 | + final String finalPath = path ?? recordedPath ?? ''; | ||
| 1696 | + if (!mounted || requestId != _voiceRequestId) { | ||
| 1697 | + _deleteAgentVoiceFile(finalPath); | ||
| 1698 | + return; | ||
| 1699 | + } | ||
| 1700 | + if (finalPath.isEmpty || !File(finalPath).existsSync()) { | ||
| 1701 | + _resetVoiceAfterError('没有录制到语音,请重试'); | ||
| 1702 | + return; | ||
| 1703 | + } | ||
| 1704 | + | ||
| 1705 | + setState(() { | ||
| 1706 | + _voiceStage = _VoiceStage.preparing; | ||
| 1707 | + _voiceSeconds = 0; | ||
| 1708 | + _recognizedText = '正在转写…'; | ||
| 1709 | + _voiceHint = '正在转写语音…'; | ||
| 1710 | + }); | ||
| 1711 | + | ||
| 1712 | + try { | ||
| 1713 | + final AgentCredentials? credentials = _agentCredentials(); | ||
| 1714 | + if (credentials == null) { | ||
| 1715 | + throw const AgentApiException(0, '未获取到登录信息,请重新登录'); | ||
| 1716 | + } | ||
| 1717 | + await _agentApi.prepare(credentials); | ||
| 1718 | + final String mimeType = lookupMimeType(finalPath) ?? 'audio/wav'; | ||
| 1719 | + final String text = await _agentApi.transcribeAudio( | ||
| 1720 | + filePath: finalPath, | ||
| 1721 | + fileName: finalPath.split('/').last, | ||
| 1722 | + mimeType: mimeType, | ||
| 1723 | + ); | ||
| 1724 | + _deleteAgentVoiceFile(finalPath); | ||
| 1725 | + if (!mounted || requestId != _voiceRequestId) return; | ||
| 1726 | + if (text.isEmpty) { | ||
| 1727 | + _resetVoiceAfterError('没有识别到内容,请重说'); | ||
| 1728 | + return; | ||
| 1729 | + } | ||
| 1730 | + await _finishVoiceWithText(text, isVoice: true); | ||
| 1731 | + } on AgentApiException catch (error) { | ||
| 1732 | + _deleteAgentVoiceFile(finalPath); | ||
| 1733 | + if (mounted && requestId == _voiceRequestId) { | ||
| 1734 | + _resetVoiceAfterError(error.message); | ||
| 1735 | + } | ||
| 1736 | + } catch (error) { | ||
| 1737 | + debugPrint('Agent voice transcription failed: $error'); | ||
| 1738 | + _deleteAgentVoiceFile(finalPath); | ||
| 1739 | + if (mounted && requestId == _voiceRequestId) { | ||
| 1740 | + _resetVoiceAfterError('语音转写失败,请重试'); | ||
| 1741 | + } | ||
| 1742 | + } | ||
| 1743 | + } | ||
| 1744 | + | ||
| 1745 | + Future<void> _cancelAgentVoice({bool dispose = false}) async { | ||
| 1746 | + _voicePressActive = false; | ||
| 1747 | + _voiceTimer?.cancel(); | ||
| 1748 | + _voiceRequestId++; | ||
| 1749 | + final AudioRecorder? recorder = _agentAudioRecorder; | ||
| 1750 | + final String? path = _agentAudioPath; | ||
| 1751 | + _agentAudioRecorder = null; | ||
| 1752 | + _agentAudioPath = null; | ||
| 1753 | + if (recorder != null) { | ||
| 1754 | + try { | ||
| 1755 | + await recorder.cancel(); | ||
| 1756 | + } catch (_) {} | ||
| 1757 | + if (dispose) { | ||
| 1758 | + try { | ||
| 1759 | + await recorder.dispose(); | ||
| 1760 | + } catch (_) {} | ||
| 1761 | + } | ||
| 1762 | + } | ||
| 1763 | + _deleteAgentVoiceFile(path); | ||
| 1764 | + } | ||
| 1765 | + | ||
| 864 | /// 移动端优先使用百炼 Paraformer 实时语音识别,失败时回退到离线 Whisper。 | 1766 | /// 移动端优先使用百炼 Paraformer 实时语音识别,失败时回退到离线 Whisper。 |
| 865 | Future<void> _startCloudVoice(int requestId) async { | 1767 | Future<void> _startCloudVoice(int requestId) async { |
| 866 | _usingCloudAsr = true; | 1768 | _usingCloudAsr = true; |
| @@ -1251,6 +2153,12 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1251,6 +2153,12 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1251 | if (!mounted) return; | 2153 | if (!mounted) return; |
| 1252 | if (_voiceStage != _VoiceStage.recording) return; | 2154 | if (_voiceStage != _VoiceStage.recording) return; |
| 1253 | 2155 | ||
| 2156 | + if (_agentAudioRecorder != null) { | ||
| 2157 | + final int requestId = _voiceRequestId; | ||
| 2158 | + await _stopAgentVoice(requestId); | ||
| 2159 | + return; | ||
| 2160 | + } | ||
| 2161 | + | ||
| 1254 | if (_usingCloudAsr) { | 2162 | if (_usingCloudAsr) { |
| 1255 | final int requestId = _voiceRequestId; | 2163 | final int requestId = _voiceRequestId; |
| 1256 | await _stopCloudVoice(requestId); | 2164 | await _stopCloudVoice(requestId); |
| @@ -1314,7 +2222,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1314,7 +2222,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1314 | } | 2222 | } |
| 1315 | } | 2223 | } |
| 1316 | 2224 | ||
| 1317 | - Future<void> _finishVoiceWithText(String? text) async { | 2225 | + Future<void> _finishVoiceWithText( |
| 2226 | + String? text, { | ||
| 2227 | + bool isVoice = true, | ||
| 2228 | + }) async { | ||
| 1318 | _voicePressActive = false; | 2229 | _voicePressActive = false; |
| 1319 | _voiceTimer?.cancel(); | 2230 | _voiceTimer?.cancel(); |
| 1320 | _usingNativeSpeech = false; | 2231 | _usingNativeSpeech = false; |
| @@ -1346,6 +2257,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1346,6 +2257,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1346 | setState(() { | 2257 | setState(() { |
| 1347 | _voiceStage = _VoiceStage.confirming; | 2258 | _voiceStage = _VoiceStage.confirming; |
| 1348 | _confirmText = simplified; | 2259 | _confirmText = simplified; |
| 2260 | + _nextMessageIsVoice = isVoice; | ||
| 1349 | }); | 2261 | }); |
| 1350 | } | 2262 | } |
| 1351 | 2263 | ||
| @@ -1356,6 +2268,13 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1356,6 +2268,13 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1356 | _usingWhisper = false; | 2268 | _usingWhisper = false; |
| 1357 | _usingNativeSpeech = false; | 2269 | _usingNativeSpeech = false; |
| 1358 | _usingCloudAsr = false; | 2270 | _usingCloudAsr = false; |
| 2271 | + _nextMessageIsVoice = false; | ||
| 2272 | + final AudioRecorder? recorder = _agentAudioRecorder; | ||
| 2273 | + final String? voicePath = _agentAudioPath; | ||
| 2274 | + _agentAudioRecorder = null; | ||
| 2275 | + _agentAudioPath = null; | ||
| 2276 | + unawaited(_disposeAgentAudioRecorder(recorder, cancelFirst: true)); | ||
| 2277 | + _deleteAgentVoiceFile(voicePath); | ||
| 1359 | unawaited(_cloudAsrSession?.cancel()); | 2278 | unawaited(_cloudAsrSession?.cancel()); |
| 1360 | _cloudAsrSession = null; | 2279 | _cloudAsrSession = null; |
| 1361 | _whisperUpdateSub?.cancel(); | 2280 | _whisperUpdateSub?.cancel(); |
| @@ -1378,6 +2297,13 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1378,6 +2297,13 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1378 | _voiceTimer?.cancel(); | 2297 | _voiceTimer?.cancel(); |
| 1379 | _voiceRequestId++; | 2298 | _voiceRequestId++; |
| 1380 | _usingWhisper = false; | 2299 | _usingWhisper = false; |
| 2300 | + _nextMessageIsVoice = false; | ||
| 2301 | + final AudioRecorder? recorder = _agentAudioRecorder; | ||
| 2302 | + final String? voicePath = _agentAudioPath; | ||
| 2303 | + _agentAudioRecorder = null; | ||
| 2304 | + _agentAudioPath = null; | ||
| 2305 | + unawaited(_disposeAgentAudioRecorder(recorder, cancelFirst: true)); | ||
| 2306 | + _deleteAgentVoiceFile(voicePath); | ||
| 1381 | if (_usingCloudAsr) { | 2307 | if (_usingCloudAsr) { |
| 1382 | _usingCloudAsr = false; | 2308 | _usingCloudAsr = false; |
| 1383 | unawaited(_cloudAsrSession?.cancel()); | 2309 | unawaited(_cloudAsrSession?.cancel()); |
| @@ -1395,6 +2321,18 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1395,6 +2321,18 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1395 | _voiceTimer?.cancel(); | 2321 | _voiceTimer?.cancel(); |
| 1396 | _voiceRequestId++; | 2322 | _voiceRequestId++; |
| 1397 | _usingWhisper = false; | 2323 | _usingWhisper = false; |
| 2324 | + _nextMessageIsVoice = false; | ||
| 2325 | + if (_agentAudioRecorder != null) { | ||
| 2326 | + await _cancelAgentVoice(); | ||
| 2327 | + if (mounted) { | ||
| 2328 | + setState(() { | ||
| 2329 | + _voiceStage = _VoiceStage.idle; | ||
| 2330 | + _recognizedText = ''; | ||
| 2331 | + _voiceHint = ''; | ||
| 2332 | + }); | ||
| 2333 | + } | ||
| 2334 | + return; | ||
| 2335 | + } | ||
| 1398 | if (_usingCloudAsr) { | 2336 | if (_usingCloudAsr) { |
| 1399 | _usingCloudAsr = false; | 2337 | _usingCloudAsr = false; |
| 1400 | final ParaformerAsrSession? session = _cloudAsrSession; | 2338 | final ParaformerAsrSession? session = _cloudAsrSession; |
| @@ -1434,8 +2372,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1434,8 +2372,10 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1434 | 2372 | ||
| 1435 | void _confirmVoice() { | 2373 | void _confirmVoice() { |
| 1436 | final String text = _confirmText.trim(); | 2374 | final String text = _confirmText.trim(); |
| 2375 | + final String inputType = _nextMessageIsVoice ? 'voice' : 'text'; | ||
| 2376 | + _nextMessageIsVoice = false; | ||
| 1437 | setState(() => _voiceStage = _VoiceStage.idle); | 2377 | setState(() => _voiceStage = _VoiceStage.idle); |
| 1438 | - if (text.isNotEmpty) _send(text); | 2378 | + if (text.isNotEmpty) _send(text, inputType: inputType); |
| 1439 | } | 2379 | } |
| 1440 | 2380 | ||
| 1441 | Future<void> _pickReservationImage() async { | 2381 | Future<void> _pickReservationImage() async { |
| @@ -1489,6 +2429,20 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1489,6 +2429,20 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1489 | Future<void> _processReservationImageBytes(Uint8List bytes) async { | 2429 | Future<void> _processReservationImageBytes(Uint8List bytes) async { |
| 1490 | if (_imageProcessing) return; | 2430 | if (_imageProcessing) return; |
| 1491 | setState(() => _imageProcessing = true); | 2431 | setState(() => _imageProcessing = true); |
| 2432 | + | ||
| 2433 | + // 保存图片到临时目录,以便在会话气泡中展示。 | ||
| 2434 | + String? savedPath; | ||
| 2435 | + try { | ||
| 2436 | + final Directory dir = await getTemporaryDirectory(); | ||
| 2437 | + final String fileName = | ||
| 2438 | + 'reservation_${DateTime.now().millisecondsSinceEpoch}.jpg'; | ||
| 2439 | + final File f = File('${dir.path}/$fileName'); | ||
| 2440 | + await f.writeAsBytes(bytes); | ||
| 2441 | + savedPath = f.path; | ||
| 2442 | + } catch (_) { | ||
| 2443 | + // 无关紧要,识别继续。 | ||
| 2444 | + } | ||
| 2445 | + | ||
| 1492 | try { | 2446 | try { |
| 1493 | final QwenOcrClient ocr = QwenOcrClient(apiKey: _qwenApiKey); | 2447 | final QwenOcrClient ocr = QwenOcrClient(apiKey: _qwenApiKey); |
| 1494 | final ReservationImageParseResult result = | 2448 | final ReservationImageParseResult result = |
| @@ -1496,32 +2450,73 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1496,32 +2450,73 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1496 | debugPrint('千问OCR识别成功: ${result.rawText.length}字'); | 2450 | debugPrint('千问OCR识别成功: ${result.rawText.length}字'); |
| 1497 | if (!mounted) return; | 2451 | if (!mounted) return; |
| 1498 | 2452 | ||
| 2453 | + setState(() => _imageProcessing = false); | ||
| 2454 | + | ||
| 1499 | if (!result.hasAny) { | 2455 | if (!result.hasAny) { |
| 1500 | - ScaffoldMessenger.of(context).showSnackBar( | ||
| 1501 | - const SnackBar(content: Text('没有从图片中识别到预订信息')), | ||
| 1502 | - ); | 2456 | + // 没识别到任何字段,以会话形式告知用户。 |
| 2457 | + setState(() { | ||
| 2458 | + _messages.add(_ChatMessage( | ||
| 2459 | + role: _Role.user, | ||
| 2460 | + text: '我发来一张图片', | ||
| 2461 | + imagePath: savedPath, | ||
| 2462 | + )); | ||
| 2463 | + _messages.add(_ChatMessage( | ||
| 2464 | + role: _Role.agent, | ||
| 2465 | + text: '抱歉,我没有从这张图片中识别到预订信息。\n' | ||
| 2466 | + '请确认图片是否包含预订人、电话、日期和人数等信息,' | ||
| 2467 | + '也可以直接文字告诉我哦~', | ||
| 2468 | + )); | ||
| 2469 | + }); | ||
| 2470 | + _scrollToBottom(); | ||
| 2471 | + _persist(); | ||
| 2472 | + if (savedPath != null && File(savedPath).existsSync()) { | ||
| 2473 | + try { | ||
| 2474 | + await File(savedPath).delete(); | ||
| 2475 | + } catch (_) {} | ||
| 2476 | + } | ||
| 1503 | return; | 2477 | return; |
| 1504 | } | 2478 | } |
| 1505 | 2479 | ||
| 1506 | - setState(() => _imageProcessing = false); | ||
| 1507 | - await _showReservationConfirm(_normalizeReservationDate(result)); | 2480 | + // 走会话式预订流程,不弹窗。 |
| 2481 | + _startReservationConversation( | ||
| 2482 | + _normalizeReservationDate(result), | ||
| 2483 | + imagePath: savedPath, | ||
| 2484 | + ); | ||
| 1508 | } on QwenOcrException catch (error) { | 2485 | } on QwenOcrException catch (error) { |
| 1509 | debugPrint('OCR失败: ${error.toString()}'); | 2486 | debugPrint('OCR失败: ${error.toString()}'); |
| 1510 | if (mounted) { | 2487 | if (mounted) { |
| 1511 | - ScaffoldMessenger.of(context).showSnackBar( | ||
| 1512 | - SnackBar(content: Text(error.message)), | ||
| 1513 | - ); | 2488 | + setState(() => _imageProcessing = false); |
| 2489 | + setState(() { | ||
| 2490 | + _messages.add(_ChatMessage( | ||
| 2491 | + role: _Role.user, | ||
| 2492 | + text: '我发来一张图片', | ||
| 2493 | + imagePath: savedPath, | ||
| 2494 | + )); | ||
| 2495 | + _messages.add(_ChatMessage( | ||
| 2496 | + role: _Role.agent, | ||
| 2497 | + text: '识别图片时出了点问题:${error.message}\n换个图片试试,或者直接文字告诉我预订信息~', | ||
| 2498 | + )); | ||
| 2499 | + }); | ||
| 2500 | + _scrollToBottom(); | ||
| 2501 | + _persist(); | ||
| 1514 | } | 2502 | } |
| 1515 | } catch (error) { | 2503 | } catch (error) { |
| 1516 | debugPrint('OCR异常: $error'); | 2504 | debugPrint('OCR异常: $error'); |
| 1517 | if (mounted) { | 2505 | if (mounted) { |
| 1518 | - ScaffoldMessenger.of(context).showSnackBar( | ||
| 1519 | - const SnackBar(content: Text('图片识别失败,请重试')), | ||
| 1520 | - ); | ||
| 1521 | - } | ||
| 1522 | - } finally { | ||
| 1523 | - if (mounted && _imageProcessing) { | ||
| 1524 | setState(() => _imageProcessing = false); | 2506 | setState(() => _imageProcessing = false); |
| 2507 | + setState(() { | ||
| 2508 | + _messages.add(_ChatMessage( | ||
| 2509 | + role: _Role.user, | ||
| 2510 | + text: '我发来一张图片', | ||
| 2511 | + imagePath: savedPath, | ||
| 2512 | + )); | ||
| 2513 | + _messages.add(_ChatMessage( | ||
| 2514 | + role: _Role.agent, | ||
| 2515 | + text: '图片识别失败,请换个图片重试,或者直接文字告诉我预订信息~', | ||
| 2516 | + )); | ||
| 2517 | + }); | ||
| 2518 | + _scrollToBottom(); | ||
| 2519 | + _persist(); | ||
| 1525 | } | 2520 | } |
| 1526 | } | 2521 | } |
| 1527 | } | 2522 | } |
| @@ -1826,6 +2821,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1826,6 +2821,8 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1826 | return _MessageBubble( | 2821 | return _MessageBubble( |
| 1827 | message: _messages[index], | 2822 | message: _messages[index], |
| 1828 | themeColor: themeColor, | 2823 | themeColor: themeColor, |
| 2824 | + onSuggestion: _send, | ||
| 2825 | + onRetry: () => _retryAgentMessage(index), | ||
| 1829 | ); | 2826 | ); |
| 1830 | }, | 2827 | }, |
| 1831 | ), | 2828 | ), |
| @@ -1869,6 +2866,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | @@ -1869,6 +2866,7 @@ class _AgentChatPageState extends State<AgentChatPage> { | ||
| 1869 | initialText: _confirmText, | 2866 | initialText: _confirmText, |
| 1870 | themeColor: themeColor, | 2867 | themeColor: themeColor, |
| 1871 | onCancel: () { | 2868 | onCancel: () { |
| 2869 | + _nextMessageIsVoice = false; | ||
| 1872 | setState(() => _voiceStage = _VoiceStage.idle); | 2870 | setState(() => _voiceStage = _VoiceStage.idle); |
| 1873 | }, | 2871 | }, |
| 1874 | onConfirm: _confirmVoice, | 2872 | onConfirm: _confirmVoice, |
| @@ -2280,10 +3278,17 @@ class _ReservationConfirmSheetState extends State<_ReservationConfirmSheet> { | @@ -2280,10 +3278,17 @@ class _ReservationConfirmSheetState extends State<_ReservationConfirmSheet> { | ||
| 2280 | } | 3278 | } |
| 2281 | 3279 | ||
| 2282 | class _MessageBubble extends StatelessWidget { | 3280 | class _MessageBubble extends StatelessWidget { |
| 2283 | - const _MessageBubble({required this.message, required this.themeColor}); | 3281 | + const _MessageBubble({ |
| 3282 | + required this.message, | ||
| 3283 | + required this.themeColor, | ||
| 3284 | + this.onSuggestion, | ||
| 3285 | + this.onRetry, | ||
| 3286 | + }); | ||
| 2284 | 3287 | ||
| 2285 | final _ChatMessage message; | 3288 | final _ChatMessage message; |
| 2286 | final Color themeColor; | 3289 | final Color themeColor; |
| 3290 | + final ValueChanged<String>? onSuggestion; | ||
| 3291 | + final VoidCallback? onRetry; | ||
| 2287 | 3292 | ||
| 2288 | @override | 3293 | @override |
| 2289 | Widget build(BuildContext context) { | 3294 | Widget build(BuildContext context) { |
| @@ -2293,68 +3298,113 @@ class _MessageBubble extends StatelessWidget { | @@ -2293,68 +3298,113 @@ class _MessageBubble extends StatelessWidget { | ||
| 2293 | 3298 | ||
| 2294 | return Align( | 3299 | return Align( |
| 2295 | alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, | 3300 | alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, |
| 2296 | - child: Container( | ||
| 2297 | - margin: const EdgeInsets.symmetric(vertical: 5), | ||
| 2298 | - constraints: BoxConstraints( | ||
| 2299 | - maxWidth: MediaQuery.of(context).size.width * 0.82, | ||
| 2300 | - ), | ||
| 2301 | - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), | ||
| 2302 | - decoration: BoxDecoration( | ||
| 2303 | - color: bubbleColor, | ||
| 2304 | - borderRadius: BorderRadius.only( | ||
| 2305 | - topLeft: const Radius.circular(14), | ||
| 2306 | - topRight: const Radius.circular(14), | ||
| 2307 | - bottomLeft: Radius.circular(isUser ? 14 : 3), | ||
| 2308 | - bottomRight: Radius.circular(isUser ? 3 : 14), | 3301 | + child: GestureDetector( |
| 3302 | + behavior: HitTestBehavior.opaque, | ||
| 3303 | + onTap: message.status == _MessageStatus.error ? onRetry : null, | ||
| 3304 | + child: Container( | ||
| 3305 | + margin: const EdgeInsets.symmetric(vertical: 5), | ||
| 3306 | + constraints: BoxConstraints( | ||
| 3307 | + maxWidth: MediaQuery.of(context).size.width * 0.82, | ||
| 3308 | + ), | ||
| 3309 | + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), | ||
| 3310 | + decoration: BoxDecoration( | ||
| 3311 | + color: bubbleColor, | ||
| 3312 | + borderRadius: BorderRadius.only( | ||
| 3313 | + topLeft: const Radius.circular(14), | ||
| 3314 | + topRight: const Radius.circular(14), | ||
| 3315 | + bottomLeft: Radius.circular(isUser ? 14 : 3), | ||
| 3316 | + bottomRight: Radius.circular(isUser ? 3 : 14), | ||
| 3317 | + ), | ||
| 3318 | + boxShadow: isUser | ||
| 3319 | + ? null | ||
| 3320 | + : [ | ||
| 3321 | + BoxShadow( | ||
| 3322 | + color: Colors.black.withValues(alpha: 0.04), | ||
| 3323 | + blurRadius: 6, | ||
| 3324 | + offset: const Offset(0, 2), | ||
| 3325 | + ), | ||
| 3326 | + ], | ||
| 2309 | ), | 3327 | ), |
| 2310 | - boxShadow: isUser | ||
| 2311 | - ? null | ||
| 2312 | - : [ | ||
| 2313 | - BoxShadow( | ||
| 2314 | - color: Colors.black.withValues(alpha: 0.04), | ||
| 2315 | - blurRadius: 6, | ||
| 2316 | - offset: const Offset(0, 2), | 3328 | + child: Column( |
| 3329 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 3330 | + children: [ | ||
| 3331 | + if (message.imagePath != null && | ||
| 3332 | + File(message.imagePath!).existsSync()) ...[ | ||
| 3333 | + Padding( | ||
| 3334 | + padding: const EdgeInsets.only(bottom: 8), | ||
| 3335 | + child: ClipRRect( | ||
| 3336 | + borderRadius: BorderRadius.circular(10), | ||
| 3337 | + child: Image.file(File(message.imagePath!), | ||
| 3338 | + width: 180, height: 160, fit: BoxFit.cover), | ||
| 2317 | ), | 3339 | ), |
| 2318 | - ], | ||
| 2319 | - ), | ||
| 2320 | - child: Column( | ||
| 2321 | - crossAxisAlignment: CrossAxisAlignment.start, | ||
| 2322 | - children: [ | ||
| 2323 | - _buildContent(textColor), | ||
| 2324 | - if (message.card != null) ...[ | ||
| 2325 | - const SizedBox(height: 8), | ||
| 2326 | - GestureDetector( | ||
| 2327 | - behavior: HitTestBehavior.opaque, | ||
| 2328 | - onTap: () { | ||
| 2329 | - Navigator.of(context).push( | ||
| 2330 | - MaterialPageRoute( | ||
| 2331 | - builder: (_) => _AgentCardDetailPage( | ||
| 2332 | - card: message.card!, | ||
| 2333 | - themeColor: themeColor, | 3340 | + ), |
| 3341 | + ], | ||
| 3342 | + _buildContent(textColor), | ||
| 3343 | + if (message.card != null) ...[ | ||
| 3344 | + const SizedBox(height: 8), | ||
| 3345 | + GestureDetector( | ||
| 3346 | + behavior: HitTestBehavior.opaque, | ||
| 3347 | + onTap: () { | ||
| 3348 | + Navigator.of(context).push( | ||
| 3349 | + MaterialPageRoute( | ||
| 3350 | + builder: (_) => _AgentCardDetailPage( | ||
| 3351 | + card: message.card!, | ||
| 3352 | + themeColor: themeColor, | ||
| 3353 | + ), | ||
| 2334 | ), | 3354 | ), |
| 2335 | - ), | ||
| 2336 | - ); | ||
| 2337 | - }, | ||
| 2338 | - child: _CardView(card: message.card!, themeColor: themeColor), | ||
| 2339 | - ), | ||
| 2340 | - ], | ||
| 2341 | - if (message.status == _MessageStatus.error) | ||
| 2342 | - const Padding( | ||
| 2343 | - padding: EdgeInsets.only(top: 6), | ||
| 2344 | - child: Text( | ||
| 2345 | - '发送失败,点击重发', | ||
| 2346 | - style: TextStyle(fontSize: 11, color: MyColours.color_E34D59), | 3355 | + ); |
| 3356 | + }, | ||
| 3357 | + child: _CardView( | ||
| 3358 | + card: message.card!, | ||
| 3359 | + themeColor: themeColor, | ||
| 3360 | + onAction: onSuggestion), | ||
| 3361 | + ), | ||
| 3362 | + ], | ||
| 3363 | + if (message.card != null && | ||
| 3364 | + message.status == _MessageStatus.done && | ||
| 3365 | + message.card!.followUps.isNotEmpty) ...[ | ||
| 3366 | + const SizedBox(height: 8), | ||
| 3367 | + Wrap( | ||
| 3368 | + spacing: 6, | ||
| 3369 | + runSpacing: 6, | ||
| 3370 | + children: message.card!.followUps.map((String item) { | ||
| 3371 | + return GestureDetector( | ||
| 3372 | + onTap: () => onSuggestion?.call(item), | ||
| 3373 | + child: Container( | ||
| 3374 | + padding: const EdgeInsets.symmetric( | ||
| 3375 | + horizontal: 10, vertical: 5), | ||
| 3376 | + decoration: BoxDecoration( | ||
| 3377 | + color: themeColor.withValues(alpha: 0.10), | ||
| 3378 | + borderRadius: BorderRadius.circular(14), | ||
| 3379 | + border: Border.all( | ||
| 3380 | + color: themeColor.withValues(alpha: 0.18)), | ||
| 3381 | + ), | ||
| 3382 | + child: Text(item, | ||
| 3383 | + style: TextStyle(fontSize: 12, color: themeColor)), | ||
| 3384 | + ), | ||
| 3385 | + ); | ||
| 3386 | + }).toList(), | ||
| 3387 | + ), | ||
| 3388 | + ], | ||
| 3389 | + if (message.status == _MessageStatus.error) | ||
| 3390 | + const Padding( | ||
| 3391 | + padding: EdgeInsets.only(top: 6), | ||
| 3392 | + child: Text( | ||
| 3393 | + '发送失败,点击重发', | ||
| 3394 | + style: | ||
| 3395 | + TextStyle(fontSize: 11, color: MyColours.color_E34D59), | ||
| 3396 | + ), | ||
| 3397 | + ), | ||
| 3398 | + const SizedBox(height: 4), | ||
| 3399 | + Text( | ||
| 3400 | + isUser ? _timeText(context) : '经营助手 · ${_timeText(context)}', | ||
| 3401 | + style: TextStyle( | ||
| 3402 | + fontSize: 10, | ||
| 3403 | + color: isUser ? Colors.white70 : MyColours.color_999999, | ||
| 2347 | ), | 3404 | ), |
| 2348 | ), | 3405 | ), |
| 2349 | - const SizedBox(height: 4), | ||
| 2350 | - Text( | ||
| 2351 | - isUser ? _timeText(context) : '经营助手 · ${_timeText(context)}', | ||
| 2352 | - style: TextStyle( | ||
| 2353 | - fontSize: 10, | ||
| 2354 | - color: isUser ? Colors.white70 : MyColours.color_999999, | ||
| 2355 | - ), | ||
| 2356 | - ), | ||
| 2357 | - ], | 3406 | + ], |
| 3407 | + ), | ||
| 2358 | ), | 3408 | ), |
| 2359 | ), | 3409 | ), |
| 2360 | ); | 3410 | ); |
| @@ -2440,10 +3490,12 @@ class _CaretState extends State<_Caret> with SingleTickerProviderStateMixin { | @@ -2440,10 +3490,12 @@ class _CaretState extends State<_Caret> with SingleTickerProviderStateMixin { | ||
| 2440 | } | 3490 | } |
| 2441 | 3491 | ||
| 2442 | class _CardView extends StatelessWidget { | 3492 | class _CardView extends StatelessWidget { |
| 2443 | - const _CardView({required this.card, required this.themeColor}); | 3493 | + const _CardView( |
| 3494 | + {required this.card, required this.themeColor, this.onAction}); | ||
| 2444 | 3495 | ||
| 2445 | final _AgentCard card; | 3496 | final _AgentCard card; |
| 2446 | final Color themeColor; | 3497 | final Color themeColor; |
| 3498 | + final ValueChanged<String>? onAction; | ||
| 2447 | 3499 | ||
| 2448 | @override | 3500 | @override |
| 2449 | Widget build(BuildContext context) { | 3501 | Widget build(BuildContext context) { |
| @@ -2467,15 +3519,47 @@ class _CardView extends StatelessWidget { | @@ -2467,15 +3519,47 @@ class _CardView extends StatelessWidget { | ||
| 2467 | ), | 3519 | ), |
| 2468 | const SizedBox(height: 8), | 3520 | const SizedBox(height: 8), |
| 2469 | _buildItems(), | 3521 | _buildItems(), |
| 3522 | + if (card.quickActions.isNotEmpty && onAction != null) ...[ | ||
| 3523 | + const SizedBox(height: 10), | ||
| 3524 | + Wrap( | ||
| 3525 | + spacing: 6, | ||
| 3526 | + runSpacing: 6, | ||
| 3527 | + children: card.quickActions.map((String action) { | ||
| 3528 | + final bool isPrimary = | ||
| 3529 | + action.contains('确认') || action.contains('好了'); | ||
| 3530 | + return GestureDetector( | ||
| 3531 | + onTap: () => onAction!.call(action), | ||
| 3532 | + child: Container( | ||
| 3533 | + padding: | ||
| 3534 | + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), | ||
| 3535 | + decoration: BoxDecoration( | ||
| 3536 | + color: isPrimary | ||
| 3537 | + ? themeColor | ||
| 3538 | + : themeColor.withValues(alpha: 0.10), | ||
| 3539 | + borderRadius: BorderRadius.circular(16), | ||
| 3540 | + border: isPrimary | ||
| 3541 | + ? null | ||
| 3542 | + : Border.all( | ||
| 3543 | + color: themeColor.withValues(alpha: 0.25)), | ||
| 3544 | + ), | ||
| 3545 | + child: Text(action, | ||
| 3546 | + style: TextStyle( | ||
| 3547 | + fontSize: 12, | ||
| 3548 | + fontWeight: | ||
| 3549 | + isPrimary ? FontWeight.w600 : FontWeight.w400, | ||
| 3550 | + color: isPrimary ? Colors.white : themeColor)), | ||
| 3551 | + ), | ||
| 3552 | + ); | ||
| 3553 | + }).toList(), | ||
| 3554 | + ), | ||
| 3555 | + ], | ||
| 2470 | if (card.kind != _CardKind.plain) ...[ | 3556 | if (card.kind != _CardKind.plain) ...[ |
| 2471 | const SizedBox(height: 8), | 3557 | const SizedBox(height: 8), |
| 2472 | Row( | 3558 | Row( |
| 2473 | mainAxisAlignment: MainAxisAlignment.end, | 3559 | mainAxisAlignment: MainAxisAlignment.end, |
| 2474 | children: [ | 3560 | children: [ |
| 2475 | - Text( | ||
| 2476 | - '点击查看详情', | ||
| 2477 | - style: TextStyle(fontSize: 10, color: themeColor), | ||
| 2478 | - ), | 3561 | + Text('点击查看详情', |
| 3562 | + style: TextStyle(fontSize: 10, color: themeColor)), | ||
| 2479 | const SizedBox(width: 2), | 3563 | const SizedBox(width: 2), |
| 2480 | Icon(Icons.chevron_right, size: 12, color: themeColor), | 3564 | Icon(Icons.chevron_right, size: 12, color: themeColor), |
| 2481 | ], | 3565 | ], |
| @@ -2528,6 +3612,30 @@ class _CardView extends StatelessWidget { | @@ -2528,6 +3612,30 @@ class _CardView extends StatelessWidget { | ||
| 2528 | ); | 3612 | ); |
| 2529 | }).toList(), | 3613 | }).toList(), |
| 2530 | ); | 3614 | ); |
| 3615 | + case _CardKind.reservationDraft: | ||
| 3616 | + return Column( | ||
| 3617 | + children: card.items.map((Map<String, dynamic> item) { | ||
| 3618 | + return Padding( | ||
| 3619 | + padding: const EdgeInsets.symmetric(vertical: 2), | ||
| 3620 | + child: Row( | ||
| 3621 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 3622 | + children: [ | ||
| 3623 | + Text('${item['label'] ?? ''}', | ||
| 3624 | + style: const TextStyle( | ||
| 3625 | + fontSize: 11, color: MyColours.color_999999)), | ||
| 3626 | + const SizedBox(width: 10), | ||
| 3627 | + Expanded( | ||
| 3628 | + child: Text('${item['value'] ?? ''}', | ||
| 3629 | + textAlign: TextAlign.right, | ||
| 3630 | + style: const TextStyle( | ||
| 3631 | + fontSize: 12, | ||
| 3632 | + fontWeight: FontWeight.w600, | ||
| 3633 | + color: Color(0xFF303030)))), | ||
| 3634 | + ], | ||
| 3635 | + ), | ||
| 3636 | + ); | ||
| 3637 | + }).toList(), | ||
| 3638 | + ); | ||
| 2531 | case _CardKind.dishRank: | 3639 | case _CardKind.dishRank: |
| 2532 | return Column( | 3640 | return Column( |
| 2533 | children: card.items | 3641 | children: card.items |
| @@ -2797,6 +3905,14 @@ class _AgentCardDetailPage extends StatelessWidget { | @@ -2797,6 +3905,14 @@ class _AgentCardDetailPage extends StatelessWidget { | ||
| 2797 | onTap: () => _openOrderDetail(context, '#2026091720'), | 3905 | onTap: () => _openOrderDetail(context, '#2026091720'), |
| 2798 | ), | 3906 | ), |
| 2799 | ]; | 3907 | ]; |
| 3908 | + case _CardKind.reservationDraft: | ||
| 3909 | + return card.items.map((Map<String, dynamic> item) { | ||
| 3910 | + return _DetailRow( | ||
| 3911 | + '${item['label'] ?? ''}', | ||
| 3912 | + '${item['value'] ?? ''}', | ||
| 3913 | + color: themeColor, | ||
| 3914 | + ); | ||
| 3915 | + }).toList(); | ||
| 2800 | case _CardKind.dishRank: | 3916 | case _CardKind.dishRank: |
| 2801 | final List<_DetailRow> rows = <_DetailRow>[]; | 3917 | final List<_DetailRow> rows = <_DetailRow>[]; |
| 2802 | for (final Map<String, dynamic> item in card.items) { | 3918 | for (final Map<String, dynamic> item in card.items) { |
lib/pages/agent/agent_reply_service.dart
0 → 100644
| 1 | +/// 经营助手“回复数据源”统一契约。 | ||
| 2 | +/// | ||
| 3 | +/// 后续接入后台接口时,实现 [AgentReplyService] 并返回 [AgentReplyOutput], | ||
| 4 | +/// 其中 `card` 使用与本地会话持久化一致的 JSON 结构(kind/title/items/followUps), | ||
| 5 | +/// 页面即可无缝切换为后台真实数据,无需改动 UI 层。 | ||
| 6 | +class AgentReplyTurn { | ||
| 7 | + const AgentReplyTurn({required this.role, required this.content}); | ||
| 8 | + | ||
| 9 | + /// user 或 assistant。 | ||
| 10 | + final String role; | ||
| 11 | + final String content; | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +class AgentReplyRequest { | ||
| 15 | + const AgentReplyRequest({ | ||
| 16 | + required this.storeId, | ||
| 17 | + required this.storeName, | ||
| 18 | + required this.question, | ||
| 19 | + required this.history, | ||
| 20 | + }); | ||
| 21 | + | ||
| 22 | + final String storeId; | ||
| 23 | + final String storeName; | ||
| 24 | + final String question; | ||
| 25 | + | ||
| 26 | + /// 最近几轮对话,用于连续追问。 | ||
| 27 | + final List<AgentReplyTurn> history; | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +class AgentReplyOutput { | ||
| 31 | + const AgentReplyOutput({ | ||
| 32 | + required this.intent, | ||
| 33 | + required this.text, | ||
| 34 | + this.card, | ||
| 35 | + this.followUps = const <String>[], | ||
| 36 | + }); | ||
| 37 | + | ||
| 38 | + /// 意图标识,便于日志与后续埋点。 | ||
| 39 | + final String intent; | ||
| 40 | + | ||
| 41 | + /// 展示给用户的一句话/一段话回复。 | ||
| 42 | + final String text; | ||
| 43 | + | ||
| 44 | + /// 结构化业务卡片,字段与 [AgentChatStore] 持久化格式一致: | ||
| 45 | + /// `{kind, title, items, followUps}`。 | ||
| 46 | + final Map<String, dynamic>? card; | ||
| 47 | + | ||
| 48 | + final List<String> followUps; | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +/// 后台回复服务接口(预留)。 | ||
| 52 | +/// | ||
| 53 | +/// 现阶段由页面内置的本地模拟逻辑兜底;后续实现 | ||
| 54 | +/// `BackendAgentReplyService` 调用门店经营接口即可替换。 | ||
| 55 | +abstract class AgentReplyService { | ||
| 56 | + Future<AgentReplyOutput> reply(AgentReplyRequest request); | ||
| 57 | +} |
lib/pages/agent/qwen_chat_client.dart
0 → 100644
| 1 | +import 'dart:convert'; | ||
| 2 | + | ||
| 3 | +import 'package:dio/dio.dart'; | ||
| 4 | + | ||
| 5 | +/// 阿里云百炼千问文本模型返回的意图识别结果。 | ||
| 6 | +class AgentIntentResult { | ||
| 7 | + const AgentIntentResult({ | ||
| 8 | + required this.intent, | ||
| 9 | + required this.reply, | ||
| 10 | + required this.entities, | ||
| 11 | + }); | ||
| 12 | + | ||
| 13 | + /// 意图标识,如 revenue、empty_tables、recent_orders、chat 等。 | ||
| 14 | + final String intent; | ||
| 15 | + | ||
| 16 | + /// 面向用户的一句话回复,要求自然、口语化。 | ||
| 17 | + final String reply; | ||
| 18 | + | ||
| 19 | + /// 意图附带的结构化实体,如 party、table、order_no、time_range。 | ||
| 20 | + final Map<String, dynamic> entities; | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +class QwenChatException implements Exception { | ||
| 24 | + const QwenChatException(this.code, this.message); | ||
| 25 | + | ||
| 26 | + final Object code; | ||
| 27 | + final String message; | ||
| 28 | + | ||
| 29 | + @override | ||
| 30 | + String toString() => 'QwenChatException($code, $message)'; | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +/// 经营助手对话大脑:用千问做自然语言理解、意图识别与友好回复。 | ||
| 34 | +/// | ||
| 35 | +/// 结构化业务卡片仍在 App 本地生成,LLM 只负责“理解用户 + 说人话”, | ||
| 36 | +/// 这样既能贴近豆包式的自然对话体验,又能保证数据卡片稳定可交互。 | ||
| 37 | +class QwenChatClient { | ||
| 38 | + QwenChatClient({ | ||
| 39 | + required this.apiKey, | ||
| 40 | + this.model = 'qwen-plus', | ||
| 41 | + }); | ||
| 42 | + | ||
| 43 | + final String apiKey; | ||
| 44 | + final String model; | ||
| 45 | + | ||
| 46 | + static const String _endpoint = | ||
| 47 | + 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'; | ||
| 48 | + | ||
| 49 | + final Dio _dio = Dio( | ||
| 50 | + BaseOptions( | ||
| 51 | + connectTimeout: const Duration(seconds: 12), | ||
| 52 | + receiveTimeout: const Duration(seconds: 30), | ||
| 53 | + responseType: ResponseType.json, | ||
| 54 | + ), | ||
| 55 | + ); | ||
| 56 | + | ||
| 57 | + static const List<String> intents = <String>[ | ||
| 58 | + 'revenue', // 营业额/经营概览 | ||
| 59 | + 'order_count', // 订单量 | ||
| 60 | + 'avg_spend', // 人均消费 | ||
| 61 | + 'compare', // 同比环比 | ||
| 62 | + 'period_stats', // 指定时间段统计 | ||
| 63 | + 'empty_tables', // 当前空桌 | ||
| 64 | + 'tonight_tables', // 今晚空桌 | ||
| 65 | + 'table_by_party', // 指定人数可用桌位 | ||
| 66 | + 'table_status', // 桌位状态 | ||
| 67 | + 'today_bookings', // 今日预订 | ||
| 68 | + 'recent_bookings', // 近期预订 | ||
| 69 | + 'confirmed_bookings', // 已确认预订 | ||
| 70 | + 'unconfirmed_bookings', // 未确认预订 | ||
| 71 | + 'booking_detail', // 预订详情 | ||
| 72 | + 'recent_orders', // 近期订单 | ||
| 73 | + 'order_by_no', // 指定订单 | ||
| 74 | + 'order_by_table', // 指定桌位订单 | ||
| 75 | + 'pay_status', // 支付状态 | ||
| 76 | + 'hot_dishes', // 热销菜品 | ||
| 77 | + 'dish_sales_rank', // 销量排行 | ||
| 78 | + 'dish_amount_rank', // 销售额排行 | ||
| 79 | + 'slow_dishes', // 滞销菜品 | ||
| 80 | + 'booking', // 用户想录入一条预订 | ||
| 81 | + 'chat', // 闲聊或无法归类 | ||
| 82 | + ]; | ||
| 83 | + | ||
| 84 | + String _systemPrompt(String storeName, String today) { | ||
| 85 | + return '你是「$storeName」餐厅的 AI 经营助手,名字叫“小周”,语气专业但亲切、像靠谱的店长助理。' | ||
| 86 | + '今天是 $today。' | ||
| 87 | + '你能帮老板查询经营数据、桌位情况、预订情况、订单查询和菜品销售情况,并支持连续追问(结合上文继承门店、日期和查询条件)。\n\n' | ||
| 88 | + '你的能力范围:\n' | ||
| 89 | + '1. 经营数据:营业额、订单量、人均消费、指定时间段统计、同比环比;\n' | ||
| 90 | + '2. 桌位情况:当前空桌、今晚空桌、指定人数可用桌位、桌位状态;\n' | ||
| 91 | + '3. 预订情况:今日/近期预订、已确认、未确认、预订详情;\n' | ||
| 92 | + '4. 订单查询:近期订单、指定订单、指定桌位订单、支付状态;\n' | ||
| 93 | + '5. 菜品销售:热销菜品、销量排行、销售额排行、滞销菜品。\n\n' | ||
| 94 | + '请结合对话历史理解用户的连续追问(例如用户先问“今天营业额”,再说“那昨天呢”,应理解为查昨天营业额)。' | ||
| 95 | + '只输出一个 JSON 对象,不要输出任何解释、Markdown 代码块或多余文字。\n\n' | ||
| 96 | + 'JSON 字段:\n' | ||
| 97 | + '- intent: 用户意图,必须是下面列表中的一个值;\n' | ||
| 98 | + '${intents.map((String i) => '- $i').join('\n')}\n' | ||
| 99 | + '- reply: 给用户的一句话回复。要自然、口语化,像真人在回复;业务类回复请包含合理的具体数字和一句可执行的建议;语气不要像客服模板。\n' | ||
| 100 | + '- entities: 提取到的实体对象,字段可为 party(人数,数字)、table(桌号/包间号)、order_no(订单号)、time_range(如“今天/今晚/昨天/近7天”)。没有则为空对象。\n\n' | ||
| 101 | + '输出示例:\n' | ||
| 102 | + '{"intent":"tonight_tables","reply":"今晚 20 点大概还有 8 张空桌,其中 2 桌是待确认状态,建议有客人先锁 A12 或 C03。","entities":{"time_range":"今晚"}}'; | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + /// 调用千问进行意图识别与回复生成。 | ||
| 106 | + /// | ||
| 107 | + /// [history] 为早前的对话(role: user/assistant,content: 文本)。 | ||
| 108 | + /// 失败时抛出 [QwenChatException]。 | ||
| 109 | + Future<AgentIntentResult> chat({ | ||
| 110 | + required String storeName, | ||
| 111 | + required List<Map<String, String>> history, | ||
| 112 | + required String question, | ||
| 113 | + }) async { | ||
| 114 | + final DateTime now = DateTime.now(); | ||
| 115 | + final String today = '${now.year}-${now.month.toString().padLeft(2, '0')}-' | ||
| 116 | + '${now.day.toString().padLeft(2, '0')}'; | ||
| 117 | + | ||
| 118 | + final List<Map<String, String>> messages = <Map<String, String>>[ | ||
| 119 | + <String, String>{ | ||
| 120 | + 'role': 'system', | ||
| 121 | + 'content': _systemPrompt(storeName, today) | ||
| 122 | + }, | ||
| 123 | + ...history, | ||
| 124 | + <String, String>{'role': 'user', 'content': question}, | ||
| 125 | + ]; | ||
| 126 | + | ||
| 127 | + final Response<Map<String, dynamic>> response; | ||
| 128 | + try { | ||
| 129 | + response = await _dio.post<Map<String, dynamic>>( | ||
| 130 | + _endpoint, | ||
| 131 | + data: <String, dynamic>{ | ||
| 132 | + 'model': model, | ||
| 133 | + 'temperature': 0.5, | ||
| 134 | + 'messages': messages, | ||
| 135 | + }, | ||
| 136 | + options: Options( | ||
| 137 | + headers: <String, String>{ | ||
| 138 | + 'Authorization': 'Bearer $apiKey', | ||
| 139 | + 'Content-Type': 'application/json', | ||
| 140 | + }, | ||
| 141 | + ), | ||
| 142 | + ); | ||
| 143 | + } on DioException catch (error) { | ||
| 144 | + throw QwenChatException( | ||
| 145 | + error.response?.statusCode ?? 0, | ||
| 146 | + _friendlyDioError(error), | ||
| 147 | + ); | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + final Map<String, dynamic>? data = response.data; | ||
| 151 | + if (data == null) { | ||
| 152 | + throw const QwenChatException(0, '对话返回数据异常'); | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + final dynamic error = data['error']; | ||
| 156 | + if (error is Map) { | ||
| 157 | + throw QwenChatException( | ||
| 158 | + error['code']?.toString() ?? '0', | ||
| 159 | + error['message']?.toString() ?? '对话请求失败', | ||
| 160 | + ); | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + final List<dynamic>? choices = data['choices'] as List<dynamic>?; | ||
| 164 | + final dynamic message = choices != null && choices.isNotEmpty | ||
| 165 | + ? (choices.first as Map<String, dynamic>)['message'] | ||
| 166 | + : null; | ||
| 167 | + if (message is! Map) { | ||
| 168 | + throw const QwenChatException(0, '未返回有效回复'); | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + final String content = (message['content'] ?? '').toString().trim(); | ||
| 172 | + final Map<String, dynamic>? json = _parseJsonObject(content); | ||
| 173 | + if (json == null) { | ||
| 174 | + // 模型没按要求返回 JSON 时,直接把内容当闲聊回复。 | ||
| 175 | + return AgentIntentResult( | ||
| 176 | + intent: 'chat', | ||
| 177 | + reply: content, | ||
| 178 | + entities: const <String, dynamic>{}, | ||
| 179 | + ); | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + return AgentIntentResult( | ||
| 183 | + intent: (json['intent'] ?? 'chat').toString().trim().toLowerCase(), | ||
| 184 | + reply: (json['reply'] ?? content).toString().trim(), | ||
| 185 | + entities: json['entities'] is Map | ||
| 186 | + ? Map<String, dynamic>.from(json['entities'] as Map) | ||
| 187 | + : const <String, dynamic>{}, | ||
| 188 | + ); | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + Map<String, dynamic>? _parseJsonObject(String content) { | ||
| 192 | + final String trimmed = content.trim(); | ||
| 193 | + if (trimmed.isEmpty) return null; | ||
| 194 | + final int start = trimmed.indexOf('{'); | ||
| 195 | + final int end = trimmed.lastIndexOf('}'); | ||
| 196 | + if (start < 0 || end <= start) return null; | ||
| 197 | + try { | ||
| 198 | + final dynamic decoded = jsonDecode(trimmed.substring(start, end + 1)); | ||
| 199 | + return decoded is Map<String, dynamic> ? decoded : null; | ||
| 200 | + } catch (_) { | ||
| 201 | + return null; | ||
| 202 | + } | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + String _friendlyDioError(DioException error) { | ||
| 206 | + final int? statusCode = error.response?.statusCode; | ||
| 207 | + if (statusCode == 401) return '千问 API Key 无效,请检查百炼密钥'; | ||
| 208 | + if (statusCode == 429) return '请求太频繁或额度不足,请稍后重试'; | ||
| 209 | + if (error.type == DioExceptionType.connectionTimeout || | ||
| 210 | + error.type == DioExceptionType.receiveTimeout) { | ||
| 211 | + return '对话请求超时,请检查网络后重试'; | ||
| 212 | + } | ||
| 213 | + return '对话网络请求失败,请检查网络'; | ||
| 214 | + } | ||
| 215 | +} |
| @@ -11,6 +11,7 @@ import 'package:one_key/models/user.dart'; | @@ -11,6 +11,7 @@ import 'package:one_key/models/user.dart'; | ||
| 11 | import 'package:one_key/net/base_resp.dart'; | 11 | import 'package:one_key/net/base_resp.dart'; |
| 12 | import 'package:one_key/net/dio_util.dart'; | 12 | import 'package:one_key/net/dio_util.dart'; |
| 13 | import 'package:one_key/net/http_url.dart'; | 13 | import 'package:one_key/net/http_url.dart'; |
| 14 | +import 'package:one_key/pages/agent/agent_api_client.dart'; | ||
| 14 | 15 | ||
| 15 | import 'package:one_key/res/colors.dart'; | 16 | import 'package:one_key/res/colors.dart'; |
| 16 | import 'package:one_key/route/navigator_util.dart'; | 17 | import 'package:one_key/route/navigator_util.dart'; |
| @@ -415,6 +416,7 @@ class UserModel with ChangeNotifier { | @@ -415,6 +416,7 @@ class UserModel with ChangeNotifier { | ||
| 415 | } | 416 | } |
| 416 | 417 | ||
| 417 | App.sp!.setBool("isLogin", false); | 418 | App.sp!.setBool("isLogin", false); |
| 419 | + unawaited(AgentApiClient.instance.logout()); | ||
| 418 | // XgFlutterPlugin().stopXg(); | 420 | // XgFlutterPlugin().stopXg(); |
| 419 | NavigatorUtil.goLoginPage(context); | 421 | NavigatorUtil.goLoginPage(context); |
| 420 | } | 422 | } |
| @@ -436,6 +438,7 @@ class UserModel with ChangeNotifier { | @@ -436,6 +438,7 @@ class UserModel with ChangeNotifier { | ||
| 436 | Utils.showToast(baseResp.msg ?? "切换店铺失败,请稍后重试"); | 438 | Utils.showToast(baseResp.msg ?? "切换店铺失败,请稍后重试"); |
| 437 | return false; | 439 | return false; |
| 438 | } else { | 440 | } else { |
| 441 | + unawaited(AgentApiClient.instance.logout()); | ||
| 439 | // XgFlutterPlugin().stopXg(); | 442 | // XgFlutterPlugin().stopXg(); |
| 440 | return true; | 443 | return true; |
| 441 | } | 444 | } |
-
Please register or login to post a comment