Commit 61c1c9fbf7fef27a7b9baf86113a8710e37fc19f

Authored by weixin_45529955
1 parent 4bf56359

ai接入接口文档

  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")
  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))}")
  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))}")
  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) {
  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 +}
  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 }