Commit 61c1c9fbf7fef27a7b9baf86113a8710e37fc19f

Authored by weixin_45529955
1 parent 4bf56359

ai接入接口文档

#!/usr/bin/env python3
"""Complete rebuild of reservation conversation flow on clean git HEAD file."""
PATH = 'lib/pages/agent/agent_chat_page.dart'
with open(PATH, 'r') as f:
c = f.read()
# ===== 1. Enum =====
c = c.replace('enum _CardKind { kpi, tableList, bookingList, orderList, dishRank, plain }',
'enum _CardKind { kpi, tableList, bookingList, orderList, dishRank, plain, reservationDraft }', 1)
# ===== 2. _AgentCard =====
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}'
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}'
c = c.replace(old, new, 1)
# ===== 3. _ChatMessage =====
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}'
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}'
c = c.replace(old2, new2, 1)
# ===== 4. _cardFromJson =====
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 }'
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 }'
c = c.replace(old3, new3, 1)
# ===== 5. _cardKindFromName =====
c = c.replace(" case 'dish_rank':\n return _CardKind.dishRank;\n default:",
" case 'dish_rank':\n return _CardKind.dishRank;\n case 'reservation_draft':\n return _CardKind.reservationDraft;\n default:", 1)
# ===== 6. _cardKindName =====
c = c.replace(" case _CardKind.dishRank:\n return 'dish_rank';\n case _CardKind.plain:",
" case _CardKind.dishRank:\n return 'dish_rank';\n case _CardKind.reservationDraft:\n return 'reservation_draft';\n case _CardKind.plain:", 1)
# ===== 7. _toRecords serialization =====
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 },"
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 },"
c = c.replace(old7, new7, 1)
# ===== 8. Reservation conversation methods (insert before _usingCloudAsr) =====
insert_marker = '\n bool _usingCloudAsr = false;'
reservation_block = '''
/// ---- 预订会话式录入 ----
ReservationImageParseResult? _pendingReservation;
bool _reservationAwaitingConfirm = false;
bool _reservationRequiredComplete(ReservationImageParseResult result) {
return result.name.isNotEmpty && result.phone.isNotEmpty &&
result.party.isNotEmpty && result.date.isNotEmpty;
}
ReservationImageParseResult _mergeReservation(ReservationImageParseResult old, ReservationImageParseResult next) {
return ReservationImageParseResult(
name: next.name.isNotEmpty ? next.name : old.name,
sex: next.sex.isNotEmpty ? next.sex : old.sex,
phone: next.phone.isNotEmpty ? next.phone : old.phone,
party: next.party.isNotEmpty ? next.party : old.party,
date: next.date.isNotEmpty ? next.date : old.date,
meal: next.meal.isNotEmpty ? next.meal : old.meal,
time: next.time.isNotEmpty ? next.time : old.time,
table: next.table.isNotEmpty ? next.table : old.table,
remark: next.remark.isNotEmpty ? next.remark : old.remark,
rawText: next.rawText.isNotEmpty ? next.rawText : old.rawText,
);
}
ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) {
final DateTime now = DateTime.now();
String date = result.date.trim();
if (date.isEmpty) return result;
if (date == '今天' || date == '今日') {
date = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
} else if (date == '明天' || date == '明日') {
final DateTime t = now.add(const Duration(days: 1));
date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}';
} else if (date == '后天') {
final DateTime t = now.add(const Duration(days: 2));
date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}';
} else if (RegExp(r'^\\d{1,2}月\\d{1,2}日$').hasMatch(date)) {
final Match? m = RegExp(r'(\\d{1,2})月(\\d{1,2})日').firstMatch(date);
if (m != null) {
final int month = int.parse(m.group(1)!);
final int day = int.parse(m.group(2)!);
int year = now.year;
if (DateTime(year, month, day).isBefore(DateTime(now.year, now.month, now.day))) year++;
date = '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
}
}
return ReservationImageParseResult(name: result.name, sex: result.sex, phone: result.phone,
party: result.party, date: date, meal: result.meal, time: result.time,
table: result.table, remark: result.remark, rawText: result.rawText);
}
String _reservationIntro(ReservationImageParseResult result) {
final List<String> parts = <String>[];
if (result.name.isNotEmpty) parts.add('预订人: ${result.name}');
if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}');
if (result.party.isNotEmpty) parts.add('人数: ${result.party}人');
if (result.date.isNotEmpty) parts.add('日期: ${result.date}');
if (parts.isEmpty) return '我从信息中没有识别到完整的预订信息,咱们一起补全吧。\\n请告诉我预订人姓名、电话、人数和日期。';
return '我从信息中识别到:\\n${parts.join('\\n')}\\n\\n如有遗漏,直接回复补充即可。';
}
String _reservationSummaryText(ReservationImageParseResult result) {
final List<String> parts = <String>[];
if (result.name.isNotEmpty) parts.add('姓名: ${result.name}');
if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}');
if (result.party.isNotEmpty) parts.add('人数: ${result.party}人');
if (result.date.isNotEmpty) parts.add('日期: ${result.date}');
if (result.meal.isNotEmpty) parts.add('时段: ${result.meal}');
if (result.time.isNotEmpty) parts.add('到店时间: ${result.time}');
if (result.table.isNotEmpty) parts.add('桌台: ${result.table}');
return parts.join('\\n');
}
String _nextReservationQuestion(ReservationImageParseResult result) {
final List<String> missing = <String>[];
if (result.name.isEmpty) missing.add('预订人姓名');
if (result.phone.isEmpty) missing.add('手机号');
if (result.party.isEmpty) missing.add('用餐人数');
if (result.date.isEmpty) missing.add('预订日期');
if (missing.isEmpty) return '信息已经齐全啦,确认一下就可以录入。';
return '还缺${missing.join('、')},直接回复我即可,例如"王先生 13800138000 8人 明晚"。';
}
_AgentCard _reservationDraftCard(ReservationImageParseResult result,
{List<String> quickActions = const <String>[]}) {
final List<String> actions = quickActions.isNotEmpty ? quickActions
: const <String>['确认录入', '好了,没问题', '修改信息', '取消'];
return _AgentCard(
kind: _CardKind.reservationDraft,
title: '预订信息草稿',
items: <Map<String, dynamic>>[
{'label': '姓名', 'value': result.name.isEmpty ? '待补充' : result.name},
{'label': '电话', 'value': result.phone.isEmpty ? '待补充' : result.phone},
{'label': '人数', 'value': result.party.isEmpty ? '待补充' : '${result.party}人'},
{'label': '日期', 'value': result.date.isEmpty ? '待补充' : result.date},
if (result.meal.isNotEmpty) {'label': '时段', 'value': result.meal},
if (result.time.isNotEmpty) {'label': '到店时间', 'value': result.time},
if (result.table.isNotEmpty) {'label': '桌台', 'value': result.table},
],
quickActions: _reservationAwaitingConfirm ? actions : const <String>[],
);
}
bool _isReservationConfirmText(String text) {
final String t = text.trim();
if (RegExp(r'^(确认|确认录入|好了|好的|可以|可以录入|没问题|没问题了|录入|行|对|ok|yes)$', caseSensitive: false).hasMatch(t)) return true;
if (t.contains('确认') && !t.contains('改') && !t.contains('换') && !t.contains('不是') && !t.contains('错了')) return true;
if ((t.contains('好了') || t.contains('没问题')) && !t.contains('改') && !t.contains('不是')) return true;
return false;
}
bool _isModificationIntent(String text) {
final String t = text.trim();
return t == '修改信息' || t == '修改' || t == '改一下' || t == '改' || t == '我要修改' || t == '需要修改';
}
String _describeChanges(ReservationImageParseResult before, ReservationImageParseResult after) {
final List<String> fields = <String>[];
if (after.name.isNotEmpty && after.name != before.name) fields.add('姓名');
if (after.phone.isNotEmpty && after.phone != before.phone) fields.add('手机号');
if (after.party.isNotEmpty && after.party != before.party) fields.add('人数');
if (after.date.isNotEmpty && after.date != before.date) fields.add('日期');
if (after.meal.isNotEmpty && after.meal != before.meal) fields.add('时段');
if (after.time.isNotEmpty && after.time != before.time) fields.add('到店时间');
if (after.table.isNotEmpty && after.table != before.table) fields.add('桌台');
if (fields.isEmpty) return '';
return fields.join('、');
}
Map<String, String> _reservationToFields(ReservationImageParseResult result) {
return <String, String>{
'name': result.name, 'sex': result.sex, 'phone': result.phone,
'party': result.party, 'date': result.date, 'meal': result.meal,
'time': result.time, 'table': result.table, 'remark': result.remark,
};
}
Future<void> _startReservationConversation(ReservationImageParseResult result, {String? imagePath}) async {
if (!mounted) return;
_pendingReservation = _normalizeReservationDate(result);
_reservationAwaitingConfirm = _reservationRequiredComplete(_pendingReservation!);
setState(() {
_messages.add(_ChatMessage(
role: _Role.user,
text: imagePath != null && imagePath.isNotEmpty ? '我发来一张预订截图' : '语音录入预订信息',
imagePath: imagePath,
));
_messages.add(_ChatMessage(
role: _Role.agent,
text: _reservationIntro(_pendingReservation!),
card: _reservationDraftCard(_pendingReservation!),
));
});
_scrollToBottom();
_persist();
}
Future<void> _handleReservationClarification(String text) async {
final ReservationImageParseResult? current = _pendingReservation;
if (current == null || !mounted) return;
if (text.contains('取消') || text.contains('算了') || text.contains('不用')) {
_pendingReservation = null;
_reservationAwaitingConfirm = false;
setState(() {
_messages.add(_ChatMessage(role: _Role.user, text: text));
_messages.add(_ChatMessage(role: _Role.agent, text: '好的,已取消这次预订录入。还想查什么随时问我~'));
});
_scrollToBottom(); _persist(); return;
}
setState(() { _messages.add(_ChatMessage(role: _Role.user, text: text)); });
_scrollToBottom(); _persist();
if (_reservationAwaitingConfirm) {
if (_isReservationConfirmText(text)) {
_pendingReservation = null; _reservationAwaitingConfirm = false;
setState(() { _messages.add(_ChatMessage(role: _Role.agent, text: '好嘞,这就帮你带入预订信息,请在下个页面最后提交一下 👇')); });
_scrollToBottom(); _persist();
_openBookingWithFields(_reservationToFields(current));
return;
}
if (_isModificationIntent(text)) {
setState(() { _messages.add(_ChatMessage(role: _Role.agent,
text: '好的,请问要修改哪一项?直接告诉我就行,比如"手机号改成138xxxx""姓名改成张三"。',
card: _reservationDraftCard(current))); });
_scrollToBottom(); _persist(); return;
}
final ReservationImageParseResult parsed = ReservationImageParser.parse(text);
final bool hasUpdate = parsed.hasAny;
final ReservationImageParseResult merged = _normalizeReservationDate(_mergeReservation(current, parsed));
_pendingReservation = merged;
_reservationAwaitingConfirm = _reservationRequiredComplete(merged);
final String changed = hasUpdate ? _describeChanges(current, parsed) : '';
setState(() { _messages.add(_ChatMessage(role: _Role.agent,
text: hasUpdate
? (_reservationAwaitingConfirm ? '已修改$changed,确认无误回复"确认"即可录入。' : '已修改$changed,${_nextReservationQuestion(merged)}')
: '没有识别到具体要修改的内容,能再说一遍吗?比如"手机号改成138xxxx""姓名改成张三"。',
card: _reservationDraftCard(merged))); });
_scrollToBottom(); _persist(); return;
}
final ReservationImageParseResult parsed = ReservationImageParser.parse(text);
final ReservationImageParseResult merged = _normalizeReservationDate(_mergeReservation(current, parsed));
if (_reservationRequiredComplete(merged)) {
_pendingReservation = merged; _reservationAwaitingConfirm = true;
setState(() { _messages.add(_ChatMessage(role: _Role.agent,
text: '信息已经齐全:\\n${_reservationSummaryText(merged)}\\n\\n确认无误就回复"确认",我直接帮你录入;要修改哪一项也直接告诉我。',
card: _reservationDraftCard(merged))); });
_scrollToBottom(); _persist(); return;
}
_pendingReservation = merged; _reservationAwaitingConfirm = false;
setState(() { _messages.add(_ChatMessage(role: _Role.agent,
text: _nextReservationQuestion(merged), card: _reservationDraftCard(merged))); });
_scrollToBottom(); _persist();
}
void _maybeEnterReservationFlow(String text) {
final ReservationImageParseResult parsed = ReservationImageParser.parse(text);
if (parsed.hasAny) _startReservationConversation(parsed);
}
List<String> _followUpsFor(_CardKind kind) {
switch (kind) {
case _CardKind.kpi: return const <String>['昨天呢?', '近7天统计', '同比环比'];
case _CardKind.tableList: return const <String>['今晚空桌呢?', '4人可用桌位', '桌位状态'];
case _CardKind.bookingList: return const <String>['未确认的预订', '预订详情'];
case _CardKind.orderList: return const <String>['支付状态', '指定桌位订单'];
case _CardKind.dishRank: return const <String>['销量排行', '销售额排行', '滞销菜品'];
case _CardKind.reservationDraft:
case _CardKind.plain: return const <String>[];
}
}
_AgentCard _withFollowUps(_AgentCard card) {
return _AgentCard(
kind: card.kind, title: card.title, items: card.items,
followUps: _followUpsFor(card.kind), quickActions: card.quickActions,
);
}
'''
c = c.replace(insert_marker, reservation_block + insert_marker, 1)
# ===== 9. _CardView: add onAction + quickActions buttons =====
old_cv = '''class _CardView extends StatelessWidget {
const _CardView({required this.card, required this.themeColor});
final _AgentCard card;
final Color themeColor;'''
new_cv = '''class _CardView extends StatelessWidget {
const _CardView({required this.card, required this.themeColor, this.onAction});
final _AgentCard card;
final Color themeColor;
final ValueChanged<String>? onAction;'''
c = c.replace(old_cv, new_cv, 1)
old_detail = ''' if (card.kind != _CardKind.plain) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'点击查看详情',
style: TextStyle(fontSize: 10, color: themeColor),
),
const SizedBox(width: 2),
Icon(Icons.chevron_right, size: 12, color: themeColor),
],
),
],'''
new_detail = ''' if (card.quickActions.isNotEmpty && onAction != null) ...[
const SizedBox(height: 10),
Wrap(
spacing: 6, runSpacing: 6,
children: card.quickActions.map((String action) {
final bool isPrimary = action.contains('确认') || action.contains('好了');
return GestureDetector(
onTap: () => onAction!.call(action),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isPrimary ? themeColor : themeColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(16),
border: isPrimary ? null : Border.all(color: themeColor.withValues(alpha: 0.25)),
),
child: Text(action, style: TextStyle(
fontSize: 12, fontWeight: isPrimary ? FontWeight.w600 : FontWeight.w400,
color: isPrimary ? Colors.white : themeColor)),
),
);
}).toList(),
),
],
if (card.kind != _CardKind.plain) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('点击查看详情', style: TextStyle(fontSize: 10, color: themeColor)),
const SizedBox(width: 2),
Icon(Icons.chevron_right, size: 12, color: themeColor),
],
),
],'''
c = c.replace(old_detail, new_detail, 1)
# ===== 10. _MessageBubble: onAction + imagePath + followUps =====
old_mb = ' child: _CardView(card: message.card!, themeColor: themeColor),'
new_mb = ' child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion),'
c = c.replace(old_mb, new_mb, 1)
old_img = ' _buildContent(textColor),'
new_img = ''' if (message.imagePath != null && File(message.imagePath!).existsSync()) ...[
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(File(message.imagePath!), width: 180, height: 160, fit: BoxFit.cover),
),
),
],
_buildContent(textColor),'''
c = c.replace(old_img, new_img, 1)
# Add followUps rendering after card
old_card_block = ''' if (message.card != null) ...[
const SizedBox(height: 8),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _AgentCardDetailPage(
card: message.card!,
themeColor: themeColor,
),
),
);
},
child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion),
),
],'''
new_card_block = ''' if (message.card != null) ...[
const SizedBox(height: 8),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _AgentCardDetailPage(
card: message.card!,
themeColor: themeColor,
),
),
);
},
child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion),
),
],
if (message.card != null &&
message.status == _MessageStatus.done &&
message.card!.followUps.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6, runSpacing: 6,
children: message.card!.followUps.map((String item) {
return GestureDetector(
onTap: () => onSuggestion(item),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: themeColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: themeColor.withValues(alpha: 0.18)),
),
child: Text(item, style: TextStyle(fontSize: 12, color: themeColor)),
),
);
}).toList(),
),
],'''
if old_card_block in c:
c = c.replace(old_card_block, new_card_block, 1)
else:
print("WARNING: card block not found for replacement")
with open(PATH, 'w') as f:
f.write(c)
print(f"SUCCESS: {c.count(chr(10))} lines")
... ...
#!/usr/bin/env python3
"""Rebuild the reservation conversation flow on agent_chat_page.dart."""
import sys
PATH = 'lib/pages/agent/agent_chat_page.dart'
with open(PATH, 'r') as f:
content = f.read()
# ============================================================
# BLOCK A: Add the entire reservation conversation code block
# Insert right before "Widget build(BuildContext context)" in _AgentChatPageState
# ============================================================
# Find where to insert the new methods
insert_marker = '\n bool _usingCloudAsr = false;'
if insert_marker not in content:
print("ERROR: insert_marker not found")
sys.exit(1)
# The new state variables and methods to insert
new_block = '''
/// ---- 预订会话式录入 ----
ReservationImageParseResult? _pendingReservation;
bool _reservationAwaitingConfirm = false;
/// 辅助:图片/文字预订的必填字段是否齐全。
bool _reservationRequiredComplete(ReservationImageParseResult result) {
return result.name.isNotEmpty &&
result.phone.isNotEmpty &&
result.party.isNotEmpty &&
result.date.isNotEmpty;
}
/// 合并两次解析结果:新解析的非空字段覆盖旧值。
ReservationImageParseResult _mergeReservation(
ReservationImageParseResult old,
ReservationImageParseResult next,
) {
return ReservationImageParseResult(
name: next.name.isNotEmpty ? next.name : old.name,
sex: next.sex.isNotEmpty ? next.sex : old.sex,
phone: next.phone.isNotEmpty ? next.phone : old.phone,
party: next.party.isNotEmpty ? next.party : old.party,
date: next.date.isNotEmpty ? next.date : old.date,
meal: next.meal.isNotEmpty ? next.meal : old.meal,
time: next.time.isNotEmpty ? next.time : old.time,
table: next.table.isNotEmpty ? next.table : old.table,
remark: next.remark.isNotEmpty ? next.remark : old.remark,
rawText: next.rawText.isNotEmpty ? next.rawText : old.rawText,
);
}
/// 将相对日期转为未来具体日期。
ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) {
final DateTime now = DateTime.now();
String date = result.date.trim();
if (date.isEmpty) return result;
if (date == '今天' || date == '今日') {
date = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
} else if (date == '明天' || date == '明日') {
final DateTime t = now.add(const Duration(days: 1));
date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}';
} else if (date == '后天') {
final DateTime t = now.add(const Duration(days: 2));
date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}';
} else if (RegExp(r'^\\d{1,2}月\\d{1,2}日$').hasMatch(date)) {
final Match? m = RegExp(r'(\\d{1,2})月(\\d{1,2})日').firstMatch(date);
if (m != null) {
final int month = int.parse(m.group(1)!);
final int day = int.parse(m.group(2)!);
int year = now.year;
final DateTime target = DateTime(year, month, day);
if (target.isBefore(DateTime(now.year, now.month, now.day))) {
year++;
}
date = '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
}
}
return ReservationImageParseResult(
name: result.name, sex: result.sex, phone: result.phone,
party: result.party, date: date, meal: result.meal,
time: result.time, table: result.table, remark: result.remark,
rawText: result.rawText,
);
}
String _reservationIntro(ReservationImageParseResult result) {
final List<String> parts = <String>[];
if (result.name.isNotEmpty) parts.add('预订人: ${result.name}');
if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}');
if (result.party.isNotEmpty) parts.add('人数: ${result.party}人');
if (result.date.isNotEmpty) parts.add('日期: ${result.date}');
if (parts.isEmpty) {
return '我从截图中没有识别到完整的预订信息,咱们一起补全吧。\\n请告诉我预订人姓名、电话、人数和日期。';
}
return '我从截图中识别到以下信息,请核对:\\n${parts.join('\\n')}\\n\\n如有遗漏,直接回复补充即可。';
}
String _reservationSummaryText(ReservationImageParseResult result) {
final List<String> parts = <String>[];
if (result.name.isNotEmpty) parts.add('姓名: ${result.name}');
if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}');
if (result.party.isNotEmpty) parts.add('人数: ${result.party}人');
if (result.date.isNotEmpty) parts.add('日期: ${result.date}');
if (result.meal.isNotEmpty) parts.add('时段: ${result.meal}');
if (result.time.isNotEmpty) parts.add('到店时间: ${result.time}');
if (result.table.isNotEmpty) parts.add('桌台: ${result.table}');
return parts.join('\\n');
}
String _nextReservationQuestion(ReservationImageParseResult result) {
final List<String> missing = <String>[];
if (result.name.isEmpty) missing.add('预订人姓名');
if (result.phone.isEmpty) missing.add('手机号');
if (result.party.isEmpty) missing.add('用餐人数');
if (result.date.isEmpty) missing.add('预订日期');
if (missing.isEmpty) return '信息已经齐全啦,确认一下就可以录入。';
return '还缺${missing.join('、')},直接回复我即可,例如"王先生 13800138000 8人 明晚"。';
}
_AgentCard _reservationDraftCard(ReservationImageParseResult result,
{List<String> quickActions = const <String>[]}) {
final List<String> actions = quickActions.isNotEmpty
? quickActions
: const <String>['确认录入', '好了,没问题', '修改信息', '取消'];
return _AgentCard(
kind: _CardKind.reservationDraft,
title: '预订信息草稿',
items: <Map<String, dynamic>>[
{'label': '姓名', 'value': result.name.isEmpty ? '待补充' : result.name},
{'label': '电话', 'value': result.phone.isEmpty ? '待补充' : result.phone},
{'label': '人数', 'value': result.party.isEmpty ? '待补充' : '${result.party}人'},
{'label': '日期', 'value': result.date.isEmpty ? '待补充' : result.date},
if (result.meal.isNotEmpty) {'label': '时段', 'value': result.meal},
if (result.time.isNotEmpty) {'label': '到店时间', 'value': result.time},
if (result.table.isNotEmpty) {'label': '桌台', 'value': result.table},
],
quickActions: _reservationAwaitingConfirm ? actions : const <String>[],
);
}
bool _isReservationConfirmText(String text) {
final String trimmed = text.trim();
if (RegExp(r'^(确认|确认录入|好了|好的|可以|可以录入|没问题|没问题了|录入|行|对|ok|yes)$', caseSensitive: false)
.hasMatch(trimmed)) {
return true;
}
if (trimmed.contains('确认') && !trimmed.contains('改') && !trimmed.contains('换') && !trimmed.contains('不是') && !trimmed.contains('错了')) {
return true;
}
if ((trimmed.contains('好了') || trimmed.contains('没问题')) && !trimmed.contains('改') && !trimmed.contains('不是')) {
return true;
}
return false;
}
bool _isModificationIntent(String text) {
final String trimmed = text.trim();
return trimmed == '修改信息' || trimmed == '修改' || trimmed == '改一下' || trimmed == '改' || trimmed == '我要修改' || trimmed == '需要修改';
}
String _describeChanges(ReservationImageParseResult before, ReservationImageParseResult after) {
final List<String> fields = <String>[];
if (after.name.isNotEmpty && after.name != before.name) fields.add('姓名');
if (after.phone.isNotEmpty && after.phone != before.phone) fields.add('手机号');
if (after.party.isNotEmpty && after.party != before.party) fields.add('人数');
if (after.date.isNotEmpty && after.date != before.date) fields.add('日期');
if (after.meal.isNotEmpty && after.meal != before.meal) fields.add('时段');
if (after.time.isNotEmpty && after.time != before.time) fields.add('到店时间');
if (after.table.isNotEmpty && after.table != before.table) fields.add('桌台');
if (fields.isEmpty) return '';
return fields.join('、');
}
Map<String, String> _reservationToFields(ReservationImageParseResult result) {
return <String, String>{
'name': result.name, 'sex': result.sex, 'phone': result.phone,
'party': result.party, 'date': result.date, 'meal': result.meal,
'time': result.time, 'table': result.table, 'remark': result.remark,
};
}
Future<void> _startReservationConversation(ReservationImageParseResult result, {String? imagePath}) async {
if (!mounted) return;
_pendingReservation = _normalizeReservationDate(result);
_reservationAwaitingConfirm = _reservationRequiredComplete(_pendingReservation!);
setState(() {
_messages.add(_ChatMessage(
role: _Role.user,
text: imagePath != null && imagePath.isNotEmpty ? '我发来一张预订截图' : '语音录入预订信息',
imagePath: imagePath,
));
_messages.add(_ChatMessage(
role: _Role.agent,
text: _reservationIntro(_pendingReservation!),
card: _reservationDraftCard(_pendingReservation!),
));
});
_scrollToBottom();
_persist();
}
Future<void> _handleReservationClarification(String text) async {
final ReservationImageParseResult? current = _pendingReservation;
if (current == null || !mounted) return;
if (text.contains('取消') || text.contains('算了') || text.contains('不用')) {
_pendingReservation = null;
_reservationAwaitingConfirm = false;
setState(() {
_messages.add(_ChatMessage(role: _Role.user, text: text));
_messages.add(_ChatMessage(role: _Role.agent, text: '好的,已取消这次预订录入。还想查什么随时问我~'));
});
_scrollToBottom();
_persist();
return;
}
setState(() { _messages.add(_ChatMessage(role: _Role.user, text: text)); });
_scrollToBottom();
_persist();
if (_reservationAwaitingConfirm) {
if (_isReservationConfirmText(text)) {
_pendingReservation = null;
_reservationAwaitingConfirm = false;
setState(() {
_messages.add(_ChatMessage(role: _Role.agent, text: '好嘞,这就帮你带入预订信息,请在下个页面最后提交一下 👇'));
});
_scrollToBottom();
_persist();
_openBookingWithFields(_reservationToFields(current));
return;
}
if (_isModificationIntent(text)) {
setState(() {
_messages.add(_ChatMessage(role: _Role.agent,
text: '好的,请问要修改哪一项?直接告诉我就行,比如"手机号改成138xxxx""姓名改成张三"。',
card: _reservationDraftCard(current),
));
});
_scrollToBottom();
_persist();
return;
}
final ReservationImageParseResult parsed = ReservationImageParser.parse(text);
final bool hasUpdate = parsed.hasAny;
final ReservationImageParseResult merged = _normalizeReservationDate(_mergeReservation(current, parsed));
_pendingReservation = merged;
_reservationAwaitingConfirm = _reservationRequiredComplete(merged);
final String changed = hasUpdate ? _describeChanges(current, parsed) : '';
setState(() {
_messages.add(_ChatMessage(role: _Role.agent,
text: hasUpdate
? (_reservationAwaitingConfirm ? '已修改$changed,确认无误回复"确认"即可录入。' : '已修改$changed,${_nextReservationQuestion(merged)}')
: '没有识别到具体要修改的内容,能再说一遍吗?比如"手机号改成138xxxx""姓名改成张三"。',
card: _reservationDraftCard(merged),
));
});
_scrollToBottom();
_persist();
return;
}
final ReservationImageParseResult parsed = ReservationImageParser.parse(text);
final ReservationImageParseResult merged = _normalizeReservationDate(_mergeReservation(current, parsed));
if (_reservationRequiredComplete(merged)) {
_pendingReservation = merged;
_reservationAwaitingConfirm = true;
setState(() {
_messages.add(_ChatMessage(role: _Role.agent,
text: '信息已经齐全:\\n${_reservationSummaryText(merged)}\\n\\n确认无误就回复"确认",我直接帮你录入;要修改哪一项也直接告诉我。',
card: _reservationDraftCard(merged),
));
});
_scrollToBottom();
_persist();
return;
}
_pendingReservation = merged;
_reservationAwaitingConfirm = false;
setState(() {
_messages.add(_ChatMessage(role: _Role.agent,
text: _nextReservationQuestion(merged),
card: _reservationDraftCard(merged),
));
});
_scrollToBottom();
_persist();
}
void _maybeEnterReservationFlow(String text) {
final ReservationImageParseResult parsed = ReservationImageParser.parse(text);
if (parsed.hasAny) {
_startReservationConversation(parsed);
}
}
// ---- 追问词条 & 卡片辅助 ----
List<String> _followUpsFor(_CardKind kind) {
switch (kind) {
case _CardKind.kpi: return const <String>['昨天呢?', '近7天统计', '同比环比'];
case _CardKind.tableList: return const <String>['今晚空桌呢?', '4人可用桌位', '桌位状态'];
case _CardKind.bookingList: return const <String>['未确认的预订', '预订详情'];
case _CardKind.orderList: return const <String>['支付状态', '指定桌位订单'];
case _CardKind.dishRank: return const <String>['销量排行', '销售额排行', '滞销菜品'];
case _CardKind.reservationDraft:
case _CardKind.plain: return const <String>[];
}
}
_AgentCard _withFollowUps(_AgentCard card) {
return _AgentCard(
kind: card.kind, title: card.title, items: card.items,
followUps: _followUpsFor(card.kind), quickActions: card.quickActions,
);
}
'''
content = content.replace(insert_marker, new_block + insert_marker, 1)
# ============================================================
# BLOCK B: Update _toRecords serialization
# ============================================================
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 },"
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 },"
content = content.replace(old_ser, new_ser, 1)
# ============================================================
# BLOCK C: Update _CardView to support onAction + quickActions
# ============================================================
old_cv = '''class _CardView extends StatelessWidget {
const _CardView({required this.card, required this.themeColor});
final _AgentCard card;
final Color themeColor;'''
new_cv = '''class _CardView extends StatelessWidget {
const _CardView({required this.card, required this.themeColor, this.onAction});
final _AgentCard card;
final Color themeColor;
final ValueChanged<String>? onAction;'''
content = content.replace(old_cv, new_cv, 1)
# Add quickActions buttons rendering - find the "点击查看详情" section base
old_detail_tap = ''' if (card.kind != _CardKind.plain) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'点击查看详情',
style: TextStyle(fontSize: 10, color: themeColor),
),
const SizedBox(width: 2),
Icon(Icons.chevron_right, size: 12, color: themeColor),
],
),
],'''
new_detail_tap = ''' if (card.quickActions.isNotEmpty && onAction != null) ...[
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 6,
children: card.quickActions.map((String action) {
final bool isPrimary = action.contains('确认') || action.contains('好了');
return GestureDetector(
onTap: () => onAction?.call(action),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isPrimary ? themeColor : themeColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(16),
border: isPrimary ? null : Border.all(color: themeColor.withValues(alpha: 0.25)),
),
child: Text(action,
style: TextStyle(fontSize: 12, fontWeight: isPrimary ? FontWeight.w600 : FontWeight.w400,
color: isPrimary ? Colors.white : themeColor),
),
),
);
}).toList(),
),
],
if (card.kind != _CardKind.plain) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'点击查看详情',
style: TextStyle(fontSize: 10, color: themeColor),
),
const SizedBox(width: 2),
Icon(Icons.chevron_right, size: 12, color: themeColor),
],
),
],'''
content = content.replace(old_detail_tap, new_detail_tap, 1)
# ============================================================
# BLOCK D: Update _MessageBubble to pass onAction + show image
# ============================================================
old_mb_card = ' child: _CardView(card: message.card!, themeColor: themeColor),'
new_mb_card = ' child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion),'
content = content.replace(old_mb_card, new_mb_card, 1)
# Add image display in _MessageBubble (before text content)
old_mb_content = ' _buildContent(textColor),'
new_mb_content = ''' if (message.imagePath != null && File(message.imagePath!).existsSync()) ...[
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(File(message.imagePath!), width: 180, height: 160, fit: BoxFit.cover),
),
),
],
_buildContent(textColor),'''
content = content.replace(old_mb_content, new_mb_content, 1)
# Add followUps rendering in _MessageBubble after card
old_mb_postcard = ' if (message.card != null) ...['
new_mb_postcard = ''' if (message.card != null) ...[
const SizedBox(height: 8),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => _AgentCardDetailPage(card: message.card!, themeColor: themeColor),
));
},'''
content = content.replace(old_mb_postcard, new_mb_postcard, 1)
# Add followUps tags
if 'if (message.card != null)' in content:
# We already changed the structure above. Now add followUps rendering after _CardView
old_mb_aftercard = ' child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion),'
new_mb_aftercard = ''' child: _CardView(card: message.card!, themeColor: themeColor, onAction: onSuggestion),
),
],
if (message.card != null && message.status == _MessageStatus.done && message.card!.followUps.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: message.card!.followUps.map((String item) {
return GestureDetector(
onTap: () => onSuggestion(item),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: themeColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: themeColor.withValues(alpha: 0.18)),
),
child: Text(item, style: TextStyle(fontSize: 12, color: themeColor)),
),
);
}).toList(),
),'''
content = content.replace(old_mb_aftercard, new_mb_aftercard, 1)
with open(PATH, 'w') as f:
f.write(content)
print(f"SUCCESS: All changes applied. Lines: {content.count(chr(10))}")
... ...
#!/usr/bin/env python3
"""Fix the 10 remaining analysis errors."""
PATH = 'lib/pages/agent/agent_chat_page.dart'
with open(PATH, 'r') as f:
c = f.read()
# ===== Fix 1: Remove duplicate _maybeEnterReservationFlow (my version at line ~321) =====
# The original at line ~630 already exists. Replace my duplicate with nothing.
dup_maybe = '''
void _maybeEnterReservationFlow(String text) {
final ReservationImageParseResult parsed = ReservationImageParser.parse(text);
if (parsed.hasAny) _startReservationConversation(parsed);
}'''
if dup_maybe in c:
c = c.replace(dup_maybe, '', 1)
print("Removed duplicate _maybeEnterReservationFlow")
else:
print("WARNING: duplicate _maybeEnterReservationFlow not found")
# ===== Fix 2: Remove duplicate _normalizeReservationDate (my version) =====
# Find the one right after _mergeReservation (line ~130 area)
dup_norm = '''
ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) {
final DateTime now = DateTime.now();
String date = result.date.trim();
if (date.isEmpty) return result;
if (date == '今天' || date == '今日') {
date = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
} else if (date == '明天' || date == '明日') {
final DateTime t = now.add(const Duration(days: 1));
date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}';
} else if (date == '后天') {
final DateTime t = now.add(const Duration(days: 2));
date = '${t.year}-${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}';
} else if (RegExp(r'^\\d{1,2}月\\d{1,2}日$').hasMatch(date)) {
final Match? m = RegExp(r'(\\d{1,2})月(\\d{1,2})日').firstMatch(date);
if (m != null) {
final int month = int.parse(m.group(1)!);
final int day = int.parse(m.group(2)!);
int year = now.year;
if (DateTime(year, month, day).isBefore(DateTime(now.year, now.month, now.day))) year++;
date = '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
}
}
return ReservationImageParseResult(name: result.name, sex: result.sex, phone: result.phone,
party: result.party, date: date, meal: result.meal, time: result.time,
table: result.table, remark: result.remark, rawText: result.rawText);
}'''
if dup_norm in c:
c = c.replace(dup_norm, '', 1)
print("Removed duplicate _normalizeReservationDate")
else:
print("WARNING: duplicate _normalizeReservationDate not found - trying simpler match")
# Try to find just the signature
idx = c.find(' ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) {')
idx2 = c.find(' ReservationImageParseResult _normalizeReservationDate(ReservationImageParseResult result) {', idx + 10)
if idx2 > idx:
print(f" Found second at {idx2}, removing...")
# Find end of this method
brace_count = 0
end_idx = idx2
for i in range(idx2, len(c)):
if c[i] == '{': brace_count += 1
elif c[i] == '}':
brace_count -= 1
if brace_count == 0:
end_idx = i + 1
break
c = c[:idx2] + c[end_idx:]
print(f" Removed {end_idx - idx2} chars")
# ===== Fix 3: Update original _maybeEnterReservationFlow to use conversation flow =====
old_maybe = ''' bool _maybeEnterReservationFlow(String text) {
final String trimmed = text.trim();
if (trimmed.isEmpty) return false;
final ReservationImageParseResult result =
ReservationImageParser.parse(trimmed);
if (!_isReservationText(result)) return false;
unawaited(_showReservationConfirm(_normalizeReservationDate(result)));
return true;
}'''
new_maybe = ''' bool _maybeEnterReservationFlow(String text) {
final String trimmed = text.trim();
if (trimmed.isEmpty) return false;
final ReservationImageParseResult result =
ReservationImageParser.parse(trimmed);
if (!_isReservationText(result)) return false;
_startReservationConversation(_normalizeReservationDate(result));
return true;
}'''
if old_maybe in c:
c = c.replace(old_maybe, new_maybe, 1)
print("Updated original _maybeEnterReservationFlow")
else:
print("WARNING: original _maybeEnterReservationFlow not found")
# ===== Fix 4: Add onSuggestion parameter to _MessageBubble =====
old_mb = '''class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message, required this.themeColor});
final _ChatMessage message;
final Color themeColor;'''
new_mb = '''class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message, required this.themeColor, this.onSuggestion});
final _ChatMessage message;
final Color themeColor;
final ValueChanged<String>? onSuggestion;'''
c = c.replace(old_mb, new_mb, 1)
print("Added onSuggestion to _MessageBubble")
# ===== Fix 5: Add reservationDraft to non-exhaustive switch statements =====
# Find the two switch(card.kind) blocks in _CardView._buildItems and _AgentCardDetailPage
# First, find them
for search in [' case _CardKind.orderList:', ' case _CardKind.dishRank:']:
idx = c.find(search)
while idx >= 0:
# Check if this is inside a switch that doesn't have reservationDraft yet
context = c[idx:idx+500]
if 'reservationDraft' not in context[:200]:
# This switch needs the case added
insert_pos = idx
# Find the 'default:' in this switch
default_idx = context.find(' default:')
if default_idx >= 0:
insert_pos = idx + default_idx
# Insert before default
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:]
print(f"Added reservationDraft case at offset {insert_pos}")
idx = c.find(search, idx + 1)
# ===== Fix 6: Pass onSuggestion from _send to _MessageBubble =====
# Find where _MessageBubble is instantiated
old_inst = ' return _MessageBubble(\n message: _messages[index],\n themeColor: themeColor,\n );'
new_inst = ' return _MessageBubble(\n message: _messages[index],\n themeColor: themeColor,\n onSuggestion: _send,\n );'
c = c.replace(old_inst, new_inst, 1)
print("Wired onSuggestion to _send")
# ===== Fix 7: _handleReservationClarification should handle all user messages with pending reservation =====
# Update _send to check and route to _handleReservationClarification
old_send = ''' if (_pendingReservation != null) {\n _handleReservationClarification(text);\n return;\n }'''
# Check if this routing already exists
if '_pendingReservation' in c and '_handleReservationClarification' not in c[:2000]:
print("Need to add routing for _handleReservationClarification")
# Find the _send method and add the check
send_idx = c.find(' void _send(String rawText) {')
if send_idx >= 0:
# Find the _maybeEnterReservationFlow check
maybe_idx = c.find('if (_maybeEnterReservationFlow(text))', send_idx)
if maybe_idx >= 0:
# Insert before it
insert_text = ''' if (_pendingReservation != null) {\n _handleReservationClarification(text);\n _inputController.clear();\n FocusScope.of(context).unfocus();\n return;\n }\n\n '''
c = c[:maybe_idx] + insert_text + c[maybe_idx:]
print("Added _handleReservationClarification routing to _send")
with open(PATH, 'w') as f:
f.write(c)
print(f"\nDone. Lines: {c.count(chr(10))}")
... ...
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
int _agentInt(dynamic value) {
if (value is int) return value;
return int.tryParse(value?.toString() ?? '') ?? 0;
}
class AgentCredentials {
const AgentCredentials({
required this.uid,
required this.sellerId,
required this.loginToken,
required this.deviceName,
});
final int uid;
final int sellerId;
final String loginToken;
final String deviceName;
Map<String, dynamic> toJson() => <String, dynamic>{
'uid': uid,
'seller_id': sellerId,
'login_token': loginToken,
'device_name': deviceName,
};
}
class AgentApiException implements Exception {
const AgentApiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => 'AgentApiException($statusCode, $message)';
}
class AgentUser {
const AgentUser({
required this.userType,
required this.name,
required this.externalUserId,
required this.merchantId,
required this.merchantCode,
required this.externalRoleName,
});
final String userType;
final String name;
final int externalUserId;
final int merchantId;
final String merchantCode;
final String externalRoleName;
factory AgentUser.fromJson(Map<String, dynamic> json) => AgentUser(
userType: json['user_type']?.toString() ?? '',
name: json['name']?.toString() ?? '',
externalUserId: _agentInt(json['external_user_id']),
merchantId: _agentInt(json['merchant_id']),
merchantCode: json['merchant_code']?.toString() ?? '',
externalRoleName: json['external_role_name']?.toString() ?? '',
);
}
class AgentLoginData {
const AgentLoginData({
required this.accessToken,
required this.tokenType,
required this.expiresAt,
required this.user,
});
final String accessToken;
final String tokenType;
final DateTime? expiresAt;
final AgentUser user;
factory AgentLoginData.fromJson(Map<String, dynamic> json) {
final Map<String, dynamic> data = Map<String, dynamic>.from(
json['data'] as Map? ?? const <String, dynamic>{});
final String accessToken = data['access_token']?.toString() ?? '';
if (accessToken.isEmpty) {
throw const AgentApiException(0, '兑换 Agent Token 失败');
}
return AgentLoginData(
accessToken: accessToken,
tokenType: data['token_type']?.toString() ?? 'Bearer',
expiresAt: DateTime.tryParse(data['expires_at']?.toString() ?? ''),
user: AgentUser.fromJson(
Map<String, dynamic>.from(
data['user'] as Map? ?? const <String, dynamic>{}),
),
);
}
}
class AgentConversation {
const AgentConversation({required this.id});
final String id;
factory AgentConversation.fromJson(Map<String, dynamic> json) {
final Map<String, dynamic> data = Map<String, dynamic>.from(
json['data'] as Map? ?? const <String, dynamic>{});
final String id = data['id']?.toString() ?? '';
if (id.isEmpty) {
throw const AgentApiException(0, '创建会话失败');
}
return AgentConversation(id: id);
}
}
class AgentMessageData {
const AgentMessageData({
required this.id,
required this.role,
required this.content,
required this.status,
required this.inputType,
required this.snapshotIds,
});
final String id;
final String role;
final String content;
final String status;
final String inputType;
final List<String> snapshotIds;
factory AgentMessageData.fromJson(Map<String, dynamic> json) {
final Map<String, dynamic> data = Map<String, dynamic>.from(
json['data'] as Map? ?? const <String, dynamic>{});
final List<dynamic> snapshots = data['snapshot_ids'] is List
? data['snapshot_ids'] as List<dynamic>
: const <dynamic>[];
return AgentMessageData(
id: data['id']?.toString() ?? '',
role: data['role']?.toString() ?? 'assistant',
content: data['content']?.toString() ?? '',
status: data['status']?.toString() ?? '',
inputType: data['input_type']?.toString() ?? 'text',
snapshotIds: snapshots.whereType<String>().toList(),
);
}
}
/// Agent 商家助手接口客户端。
///
/// 页面只依赖这个客户端访问 Agent 服务;调用方传入 App 当前登录票据,
/// 由本客户端兑换并缓存短期 access_token,自动在 401 时重新兑换并重试一次。
class AgentApiClient {
AgentApiClient._();
static final AgentApiClient instance = AgentApiClient._();
static const String baseUrl = 'https://agent.colorcun.com';
static const String _tokenKey = 'agent_access_token';
static const String _expiresKey = 'agent_token_expires_at';
static const String _uidKey = 'agent_bound_uid';
static const String _sellerKey = 'agent_bound_seller_id';
final Dio _dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 130),
responseType: ResponseType.json,
),
);
String? _accessToken;
DateTime? _expiresAt;
int? _boundUid;
int? _boundSellerId;
AgentCredentials? _credentials;
bool get _tokenUsable {
final String? token = _accessToken;
final DateTime? expiresAt = _expiresAt;
if (token == null || token.isEmpty) return false;
if (expiresAt == null) return false;
return expiresAt
.subtract(const Duration(seconds: 60))
.isAfter(DateTime.now());
}
Future<void> prepare(AgentCredentials credentials) async {
_credentials = credentials;
await _loadToken();
if (!_tokenUsable ||
_boundUid != credentials.uid ||
_boundSellerId != credentials.sellerId) {
await login(credentials);
}
}
Future<AgentLoginData> login(AgentCredentials credentials) async {
final Map<String, dynamic> response = await _request(
'POST',
'/api/v1/auth/merchant/login',
data: credentials.toJson(),
auth: false,
);
final AgentLoginData result = AgentLoginData.fromJson(response);
_accessToken = result.accessToken;
_expiresAt = result.expiresAt;
_boundUid = result.user.externalUserId;
_boundSellerId = result.user.merchantId;
_credentials = credentials;
await _saveToken(result);
return result;
}
Future<AgentUser> me() async {
final Map<String, dynamic> response =
await _authorized('GET', '/api/v1/me');
final Map<String, dynamic> data = Map<String, dynamic>.from(
response['data'] as Map? ?? const <String, dynamic>{});
return AgentUser.fromJson(data);
}
Future<AgentConversation> createConversation(
{CancelToken? cancelToken}) async {
final Map<String, dynamic> response = await _authorized(
'POST',
'/api/v1/conversations',
data: const <String, dynamic>{},
cancelToken: cancelToken,
);
return AgentConversation.fromJson(response);
}
Future<AgentMessageData> sendMessage({
required String conversationId,
required String content,
required String inputType,
required String clientMessageId,
CancelToken? cancelToken,
}) async {
final Map<String, dynamic> response = await _authorized(
'POST',
'/api/v1/conversations/$conversationId/messages',
data: <String, dynamic>{
'content': content,
'input_type': inputType,
'client_message_id': clientMessageId,
},
cancelToken: cancelToken,
);
return AgentMessageData.fromJson(response);
}
Future<String> transcribeAudio({
required String filePath,
required String fileName,
required String mimeType,
CancelToken? cancelToken,
}) async {
final List<String> parts = mimeType.split('/');
final DioMediaType mediaType = parts.length == 2
? DioMediaType(parts[0], parts[1])
: DioMediaType('application', 'octet-stream');
final FormData formData = FormData.fromMap(<String, dynamic>{
'audio': await MultipartFile.fromFile(
filePath,
filename: fileName,
contentType: mediaType,
),
});
final Map<String, dynamic> response = await _authorized(
'POST',
'/api/v1/audio/transcriptions',
data: formData,
cancelToken: cancelToken,
);
final Map<String, dynamic> data = Map<String, dynamic>.from(
response['data'] as Map? ?? const <String, dynamic>{});
return data['text']?.toString().trim() ?? '';
}
Future<void> submitFeedback({
required String messageId,
required int rating,
String reason = '',
String comment = '',
}) async {
await _authorized(
'PUT',
'/api/v1/messages/$messageId/feedback',
data: <String, dynamic>{
'rating': rating,
'reason': reason,
'comment': comment,
},
);
}
Future<void> deleteConversation(String conversationId) async {
await _authorized('DELETE', '/api/v1/conversations/$conversationId');
}
/// 退出登录时调用:尝试通知 Agent 服务并立即清除本地 Token。
Future<void> logout() async {
final String? token = _accessToken;
_credentials = null;
await _clearToken();
if (token == null || token.isEmpty) return;
_accessToken = token;
try {
await _request('DELETE', '/api/v1/auth/merchant/token', auth: true);
} catch (_) {
// 网络失败时本地已清除,不再阻塞退出登录。
} finally {
_accessToken = null;
}
}
Future<Map<String, dynamic>> _authorized(
String method,
String path, {
Object? data,
Options? options,
CancelToken? cancelToken,
}) async {
if (_credentials == null) {
throw const AgentApiException(0, '未获取到 App 登录信息,请重新登录');
}
await _ensureToken();
try {
return await _request(
method,
path,
data: data,
options: options,
cancelToken: cancelToken,
auth: true,
);
} on AgentApiException catch (error) {
if (error.statusCode != 401) rethrow;
await _clearToken();
await login(_credentials!);
return _request(
method,
path,
data: data,
options: options,
cancelToken: cancelToken,
auth: true,
);
}
}
Future<void> _ensureToken() async {
await _loadToken();
final AgentCredentials? credentials = _credentials;
if (credentials == null ||
!_tokenUsable ||
_boundUid != credentials.uid ||
_boundSellerId != credentials.sellerId) {
if (credentials == null) {
throw const AgentApiException(0, '未获取到 App 登录信息,请重新登录');
}
await login(credentials);
}
}
Future<Map<String, dynamic>> _request(
String method,
String path, {
Object? data,
Options? options,
CancelToken? cancelToken,
bool auth = true,
}) async {
final Options requestOptions = options ?? Options();
requestOptions.method = method;
requestOptions.headers ??= <String, dynamic>{};
requestOptions.headers!['Accept'] = 'application/json';
if (auth) {
requestOptions.headers!['Authorization'] = 'Bearer ${_accessToken ?? ''}';
}
if (data is Map) {
requestOptions.headers!['Content-Type'] = 'application/json';
}
try {
final Response<dynamic> response = await _dio.request<dynamic>(
path,
data: data,
options: requestOptions,
cancelToken: cancelToken,
);
return _toMap(response.data);
} on DioException catch (error) {
if (error.type == DioExceptionType.cancel) rethrow;
final int status = error.response?.statusCode ?? 0;
throw AgentApiException(
status, _messageFor(status, error.response?.data));
}
}
Map<String, dynamic> _toMap(dynamic data) {
if (data == null) return <String, dynamic>{};
if (data is Map) return Map<String, dynamic>.from(data);
return <String, dynamic>{'data': data};
}
String _messageFor(int statusCode, dynamic data) {
switch (statusCode) {
case 400:
return '请求参数有误,请检查后重试';
case 401:
return 'App 登录状态无效或已过期,请重新登录';
case 403:
return '当前商家账号暂无权限,请联系管理员';
case 404:
return '会话或消息不存在,请刷新后重试';
case 422:
return '输入内容不符合要求,请修改后重试';
case 429:
return '操作太频繁,请稍后再试';
case 502:
return '业务数据服务繁忙,请稍后重试';
case 503:
return 'AI 服务暂时不可用,请稍后重试';
default:
if (data is Map) {
final dynamic message = data['message'];
if (message is String && message.trim().isNotEmpty) {
return message.trim();
}
final dynamic error = data['error'];
if (error is String && error.trim().isNotEmpty) return error.trim();
if (error is Map && error['message'] is String) {
return error['message'].toString();
}
}
return '网络请求失败,请稍后重试';
}
}
Future<void> _loadToken() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
_accessToken = prefs.getString(_tokenKey);
_expiresAt = DateTime.tryParse(prefs.getString(_expiresKey) ?? '');
_boundUid = prefs.getInt(_uidKey);
_boundSellerId = prefs.getInt(_sellerKey);
}
Future<void> _saveToken(AgentLoginData data) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, data.accessToken);
if (data.expiresAt != null) {
await prefs.setString(_expiresKey, data.expiresAt!.toIso8601String());
}
await prefs.setInt(_uidKey, data.user.externalUserId);
await prefs.setInt(_sellerKey, data.user.merchantId);
}
Future<void> _clearToken() async {
_accessToken = null;
_expiresAt = null;
_boundUid = null;
_boundSellerId = null;
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_expiresKey);
await prefs.remove(_uidKey);
await prefs.remove(_sellerKey);
}
}
... ...
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
import 'package:one_key/app.dart';
import 'package:one_key/models/notebook_detail.dart';
import 'package:one_key/models/user.dart';
import 'package:one_key/pages/agent/agent_api_client.dart';
import 'package:one_key/pages/agent/agent_chat_store.dart';
import 'package:one_key/pages/agent/qwen_ocr_client.dart';
import 'package:one_key/pages/agent/reservation_image_parser.dart';
import 'package:path_provider/path_provider.dart';
import 'package:one_key/pages/agent/shared_reservation_receiver.dart';
import 'package:one_key/pages/agent/paraformer_asr_client.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:one_key/provider/user_model.dart';
import 'package:one_key/res/colors.dart';
import 'package:one_key/route/navigator_util.dart';
import 'package:one_key/utils/utils.dart';
import 'package:provider/provider.dart';
import 'package:record/record.dart';
import 'package:speech_to_text/speech_recognition_result.dart';
import 'package:speech_to_text/speech_to_text.dart';
import 'package:whisper_cpp_flutter_plus/whisper_cpp_flutter_plus.dart';
... ... @@ -25,18 +32,30 @@ enum _Role { user, agent }
enum _MessageStatus { sending, streaming, done, error }
enum _CardKind { kpi, tableList, bookingList, orderList, dishRank, plain }
enum _CardKind {
kpi,
tableList,
bookingList,
orderList,
dishRank,
plain,
reservationDraft
}
class _AgentCard {
const _AgentCard({
required this.kind,
required this.title,
required this.items,
this.followUps = const <String>[],
this.quickActions = const <String>[],
});
final _CardKind kind;
final String title;
final List<Map<String, dynamic>> items;
final List<String> followUps;
final List<String> quickActions;
}
class _ChatMessage {
... ... @@ -45,6 +64,9 @@ class _ChatMessage {
required this.text,
this.card,
this.status = _MessageStatus.done,
this.imagePath,
this.clientMessageId,
this.inputType = 'text',
String? id,
DateTime? ts,
}) : id = id ?? '${DateTime.now().microsecondsSinceEpoch}_${role.name}',
... ... @@ -56,6 +78,9 @@ class _ChatMessage {
_AgentCard? card;
_MessageStatus status;
DateTime ts;
final String? imagePath;
final String? clientMessageId;
final String inputType;
}
class _MockReply {
... ... @@ -88,6 +113,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
final List<_ChatMessage> _messages = [];
final SpeechToText _speech = SpeechToText();
final AgentChatStore _chatStore = AgentChatStore.instance;
final AgentApiClient _agentApi = AgentApiClient.instance;
Timer? _typingTimer;
Timer? _voiceTimer;
... ... @@ -97,6 +123,472 @@ class _AgentChatPageState extends State<AgentChatPage> {
bool _historyLoading = true;
bool _usingNativeSpeech = false;
bool _usingWhisper = false;
bool _nextMessageIsVoice = false;
bool _agentAuthExpiredHandling = false;
String? _agentConversationId;
CancelToken? _agentCancelToken;
AudioRecorder? _agentAudioRecorder;
String? _agentAudioPath;
/// ---- 预订会话式录入 ----
ReservationImageParseResult? _pendingReservation;
bool _reservationAwaitingConfirm = false;
bool _reservationRequiredComplete(ReservationImageParseResult result) {
return result.name.isNotEmpty &&
result.phone.isNotEmpty &&
result.party.isNotEmpty &&
result.date.isNotEmpty;
}
ReservationImageParseResult _mergeReservation(
ReservationImageParseResult old, ReservationImageParseResult next) {
return ReservationImageParseResult(
name: next.name.isNotEmpty ? next.name : old.name,
sex: next.sex.isNotEmpty ? next.sex : old.sex,
phone: next.phone.isNotEmpty ? next.phone : old.phone,
party: next.party.isNotEmpty ? next.party : old.party,
date: next.date.isNotEmpty ? next.date : old.date,
meal: next.meal.isNotEmpty ? next.meal : old.meal,
time: next.time.isNotEmpty ? next.time : old.time,
table: next.table.isNotEmpty ? next.table : old.table,
remark: next.remark.isNotEmpty ? next.remark : old.remark,
rawText: next.rawText.isNotEmpty ? next.rawText : old.rawText,
);
}
String _reservationIntro(ReservationImageParseResult result) {
final List<String> parts = <String>[];
if (result.name.isNotEmpty) parts.add('👤 预订人:${result.name}');
if (result.phone.isNotEmpty) parts.add('📱 电话:${result.phone}');
if (result.party.isNotEmpty) parts.add('👥 人数:${result.party}人');
if (result.date.isNotEmpty) parts.add('📅 日期:${result.date}');
if (result.meal.isNotEmpty) parts.add('🍽 时段:${result.meal}');
if (result.time.isNotEmpty) parts.add('⏰ 到店时间:${result.time}');
if (result.table.isNotEmpty) parts.add('🪑 桌台:${result.table}');
if (parts.isEmpty) {
return '我没有从信息中识别到完整的预订字段,咱们一起补全吧。';
}
return '📋 识别到以下信息:\n${parts.join('\n')}';
}
String _reservationSummaryText(ReservationImageParseResult result) {
final List<String> parts = <String>[];
if (result.name.isNotEmpty) parts.add('姓名: ${result.name}');
if (result.phone.isNotEmpty) parts.add('电话: ${result.phone}');
if (result.party.isNotEmpty) parts.add('人数: ${result.party}人');
if (result.date.isNotEmpty) parts.add('日期: ${result.date}');
if (result.meal.isNotEmpty) parts.add('时段: ${result.meal}');
if (result.time.isNotEmpty) parts.add('到店时间: ${result.time}');
if (result.table.isNotEmpty) parts.add('桌台: ${result.table}');
return parts.join('\n');
}
String _nextReservationQuestion(ReservationImageParseResult result) {
final List<String> missing = <String>[];
if (result.name.isEmpty) missing.add('预订人姓名');
if (result.phone.isEmpty) missing.add('手机号');
if (result.party.isEmpty) missing.add('用餐人数');
if (result.date.isEmpty) missing.add('预订日期');
if (missing.isEmpty) return '信息已经齐全啦,确认一下就可以录入。';
return '还缺 ${missing.join('、')},直接回复补充即可,\n例如"王先生 13800138000 8人 明晚"。';
}
_AgentCard _reservationDraftCard(ReservationImageParseResult result,
{List<String> quickActions = const <String>[]}) {
final List<String> actions = quickActions.isNotEmpty
? quickActions
: const <String>['确认录入', '好了,没问题', '修改信息', '取消'];
// 字段不全时不带快捷按钮,但带追问词条引导补全
final bool complete = _reservationRequiredComplete(result);
return _AgentCard(
kind: _CardKind.reservationDraft,
title: '预订信息草稿',
followUps: complete ? const <String>[] : const <String>['我直接说', '重新识别'],
items: <Map<String, dynamic>>[
{'label': '姓名', 'value': result.name.isEmpty ? '待补充' : result.name},
{'label': '电话', 'value': result.phone.isEmpty ? '待补充' : result.phone},
{
'label': '人数',
'value': result.party.isEmpty ? '待补充' : '${result.party}人'
},
{'label': '日期', 'value': result.date.isEmpty ? '待补充' : result.date},
if (result.meal.isNotEmpty) {'label': '时段', 'value': result.meal},
if (result.time.isNotEmpty) {'label': '到店时间', 'value': result.time},
if (result.table.isNotEmpty) {'label': '桌台', 'value': result.table},
],
quickActions: _reservationAwaitingConfirm ? actions : const <String>[],
);
}
bool _isReservationConfirmText(String text) {
final String t = text.trim();
// 精确匹配所有快捷按钮文字 + 常见口语确认
if (RegExp(
r'^(确认|确认录入|好了|好了,没问题|好的|可以|可以录入|没问题|没问题了|没问题,录入吧|录入|行|对|ok|yes)$',
caseSensitive: false)
.hasMatch(t)) return true;
if (t.contains('确认') &&
!t.contains('改') &&
!t.contains('换') &&
!t.contains('不是') &&
!t.contains('错了')) return true;
if ((t.contains('好了') || t.contains('没问题')) &&
!t.contains('改') &&
!t.contains('不是')) return true;
return false;
}
bool _isModificationIntent(String text) {
final String t = text.trim();
return t == '修改信息' ||
t == '修改' ||
t == '改一下' ||
t == '改' ||
t == '我要修改' ||
t == '需要修改' ||
t == '修改一下' ||
t == '改改' ||
t == '要改' ||
t == '调整一下' ||
t == '调整';
}
/// 从自然语言修改指令中提取字段(如"把手机号改成138xxxx")。
ReservationImageParseResult _parseModificationText(String text) {
String name = '';
String phone = '';
String party = '';
String date = '';
String meal = '';
String time = '';
String table = '';
// 模式:把/将 XXX 改成/改为/换成 YYY
final RegExp modPattern = RegExp(
r'(?:把|将)?\s*(\S+?)\s*(?:改成|改为|换成|改成是|换为|修改为|改成是)\s*(\S+)',
);
for (final Match m in modPattern.allMatches(text)) {
final String field = m.group(1) ?? '';
final String value = m.group(2) ?? '';
if (field.contains('名') || field.contains('姓') || field == '名字') {
name = value;
} else if (field.contains('手机') ||
field.contains('电话') ||
field.contains('号')) {
phone = RegExp(r'1[3-9]\d{9}').firstMatch(value)?.group(0) ?? value;
} else if (field.contains('人') || field.contains('位')) {
party = RegExp(r'\d+').firstMatch(value)?.group(0) ?? '';
} else if (field.contains('日') || field.contains('期')) {
date = value;
} else if (field.contains('时') ||
field.contains('餐') ||
field.contains('饭')) {
meal = value;
} else if (field.contains('桌') ||
field.contains('台') ||
field.contains('包间')) {
table = value;
}
}
// 回退:直接尝试标准解析
if (name.isEmpty && phone.isEmpty && party.isEmpty && date.isEmpty) {
return ReservationImageParser.parse(text);
}
return ReservationImageParseResult(
name: name,
sex: '',
phone: phone,
party: party,
date: date,
meal: meal,
time: time,
table: table,
remark: '',
rawText: text,
);
}
String _describeChanges(
ReservationImageParseResult before, ReservationImageParseResult after) {
final List<String> fields = <String>[];
if (after.name.isNotEmpty && after.name != before.name) fields.add('姓名');
if (after.phone.isNotEmpty && after.phone != before.phone)
fields.add('手机号');
if (after.party.isNotEmpty && after.party != before.party) fields.add('人数');
if (after.date.isNotEmpty && after.date != before.date) fields.add('日期');
if (after.meal.isNotEmpty && after.meal != before.meal) fields.add('时段');
if (after.time.isNotEmpty && after.time != before.time) fields.add('到店时间');
if (after.table.isNotEmpty && after.table != before.table) fields.add('桌台');
if (fields.isEmpty) return '';
return fields.join('、');
}
Map<String, String> _reservationToFields(ReservationImageParseResult result) {
return <String, String>{
'name': result.name,
'sex': result.sex,
'phone': result.phone,
'party': result.party,
'date': result.date,
'meal': result.meal,
'time': result.time,
'table': result.table,
'remark': result.remark,
};
}
/// 订单确认后的摘要文本(管家风格)。
String _reservationOrderSummary(
ReservationImageParseResult result, Map<String, String> fields) {
final StringBuffer sb = StringBuffer();
final String name = fields['name']?.trim() ?? '';
final String sex = fields['sex']?.trim() ?? '';
final String phone = fields['phone']?.trim() ?? '';
final String party = fields['party']?.trim() ?? '';
final String date = fields['date']?.trim() ?? '';
final String meal = fields['meal']?.trim() ?? '';
final String time = fields['time']?.trim() ?? '';
final String table = fields['table']?.trim() ?? '';
// 第一行:核心信息一行展示
final List<String> meta = <String>[];
if (date.isNotEmpty) meta.add(date);
if (meal.isNotEmpty) meta.add(meal);
if (table.isNotEmpty) meta.add('$table桌');
sb.writeln(meta.isNotEmpty ? '📌 ${meta.join(' · ')}' : '📌 已确认');
sb.writeln('━━━━━━━━━━━━━━');
// 第二行:人物信息
final String displayName = sex.isNotEmpty ? '$name$sex' : name;
sb.write('👤 $displayName');
if (party.isNotEmpty) sb.write(' · $party人');
if (phone.isNotEmpty) sb.write(' · $phone');
sb.writeln();
// 附加信息
if (time.isNotEmpty) {
sb.writeln('⏰ 到店时间:$time');
}
final String remark = fields['remark']?.trim() ?? '';
if (remark.isNotEmpty) {
sb.writeln('📝 备注:$remark');
}
return sb.toString().trim();
}
/// 确认后的订单卡片(管家风格,带追问)。
_AgentCard _reservationOrderCard(
ReservationImageParseResult result, Map<String, String> fields) {
final String date = fields['date']?.trim() ?? '';
final String meal = fields['meal']?.trim() ?? '';
final String title = date.isNotEmpty
? (meal.isNotEmpty ? '预订确认 · $date $meal' : '预订确认 · $date')
: '预订确认 ✅';
return _AgentCard(
kind: _CardKind.plain,
title: title,
items: <Map<String, dynamic>>[
{
'label': '预订人',
'value':
'${fields['name']?.trim() ?? "-"}${(fields['sex']?.trim() ?? "").isNotEmpty ? "(${fields['sex']!.trim()})" : ""}',
},
{'label': '电话', 'value': fields['phone']?.trim() ?? "-"},
{'label': '人数', 'value': '${fields['party']?.trim() ?? "-"}人'},
if ((fields['time']?.trim() ?? '').isNotEmpty)
{'label': '到店时间', 'value': fields['time']!.trim()},
if ((fields['table']?.trim() ?? '').isNotEmpty)
{'label': '桌台', 'value': '${fields['table']!.trim()}桌'},
if ((fields['remark']?.trim() ?? '').isNotEmpty)
{'label': '备注', 'value': fields['remark']!.trim()},
],
followUps: const <String>['今晚还有空桌吗?', '查看今日预订', '查订单'],
);
}
Future<void> _startReservationConversation(ReservationImageParseResult result,
{String? imagePath}) async {
if (!mounted) return;
_pendingReservation = _normalizeReservationDate(result);
_reservationAwaitingConfirm =
_reservationRequiredComplete(_pendingReservation!);
final String intro = _reservationIntro(_pendingReservation!);
final String replyText = _reservationAwaitingConfirm
? '$intro\n\n信息齐全,确认无误即可录入。'
: '$intro\n\n⚠️ ${_nextReservationQuestion(_pendingReservation!)}';
setState(() {
_messages.add(_ChatMessage(
role: _Role.user,
text: imagePath != null && imagePath.isNotEmpty
? '我发来一张预订截图'
: '语音录入预订信息',
imagePath: imagePath,
));
_messages.add(_ChatMessage(
role: _Role.agent,
text: replyText,
card: _reservationDraftCard(_pendingReservation!),
));
});
_scrollToBottom();
_persist();
}
Future<void> _handleReservationClarification(String text) async {
final ReservationImageParseResult? current = _pendingReservation;
if (current == null || !mounted) return;
if (text.contains('取消') || text.contains('算了') || text.contains('不用')) {
_pendingReservation = null;
_reservationAwaitingConfirm = false;
setState(() {
_messages.add(_ChatMessage(role: _Role.user, text: text));
_messages.add(_ChatMessage(
role: _Role.agent,
text: '好的,已取消这次预订录入。还想查什么随时问我~',
card: _AgentCard(
kind: _CardKind.plain,
title: '已取消录入',
items: const <Map<String, dynamic>>[],
followUps: const <String>['今晚还有空桌吗?', '查看今日预订', '重新录入预订'],
),
));
});
_scrollToBottom();
_persist();
return;
}
setState(() {
_messages.add(_ChatMessage(role: _Role.user, text: text));
});
_scrollToBottom();
_persist();
if (_reservationAwaitingConfirm) {
if (_isReservationConfirmText(text)) {
final Map<String, String> fields = _reservationToFields(current);
final String summary = _reservationOrderSummary(current, fields);
_pendingReservation = null;
_reservationAwaitingConfirm = false;
// 根据多轮补全次数调整语气
final int totalRounds = _messages
.where((m) => m.role == _Role.user && m.text.contains('预订'))
.length;
final String tone =
totalRounds <= 2 ? '搞定!预订已录入 ✅' : '不容易,终于凑齐了!已帮你录入 ✅';
setState(() {
_messages.add(_ChatMessage(
role: _Role.agent,
text: '$tone\n\n$summary\n━━━━━━━━━━━━━━\n'
'还需要我帮你看看什么?',
card: _reservationOrderCard(current, fields),
));
});
_scrollToBottom();
_persist();
return;
}
if (_isModificationIntent(text)) {
setState(() {
_messages.add(_ChatMessage(
role: _Role.agent,
text: '好的,请问要修改哪一项?直接告诉我就行,比如"手机号改成138xxxx""姓名改成张三"。',
card: _reservationDraftCard(current)));
});
_scrollToBottom();
_persist();
return;
}
// 优先用标准解析器,解析失败时尝试自然语言"改成"模式
ReservationImageParseResult parsed = ReservationImageParser.parse(text);
if (!parsed.hasAny) {
parsed = _parseModificationText(text);
}
final bool hasUpdate = parsed.hasAny;
final ReservationImageParseResult merged =
_normalizeReservationDate(_mergeReservation(current, parsed));
_pendingReservation = merged;
_reservationAwaitingConfirm = _reservationRequiredComplete(merged);
final String changed = hasUpdate ? _describeChanges(current, parsed) : '';
setState(() {
_messages.add(_ChatMessage(
role: _Role.agent,
text: hasUpdate
? (_reservationAwaitingConfirm
? '已修改$changed,确认无误回复"确认"即可录入。'
: '已修改$changed,${_nextReservationQuestion(merged)}')
: '没有识别到具体要修改的内容,能再说一遍吗?\n比如"手机号改成138xxxx0000""姓名改成张三"。',
card: _reservationDraftCard(merged)));
});
_scrollToBottom();
_persist();
return;
}
final ReservationImageParseResult parsed =
ReservationImageParser.parse(text);
final ReservationImageParseResult merged =
_normalizeReservationDate(_mergeReservation(current, parsed));
if (_reservationRequiredComplete(merged)) {
_pendingReservation = merged;
_reservationAwaitingConfirm = true;
setState(() {
_messages.add(_ChatMessage(
role: _Role.agent,
text:
'信息已经齐全:\n${_reservationSummaryText(merged)}\n\n确认无误就回复"确认",我直接帮你录入;要修改哪一项也直接告诉我。',
card: _reservationDraftCard(merged)));
});
_scrollToBottom();
_persist();
return;
}
_pendingReservation = merged;
_reservationAwaitingConfirm = false;
setState(() {
_messages.add(_ChatMessage(
role: _Role.agent,
text: _nextReservationQuestion(merged),
card: _reservationDraftCard(merged)));
});
_scrollToBottom();
_persist();
}
List<String> _followUpsFor(_CardKind kind) {
switch (kind) {
case _CardKind.kpi:
return const <String>['昨天呢?', '近7天统计', '同比环比'];
case _CardKind.tableList:
return const <String>['今晚空桌呢?', '4人可用桌位', '桌位状态'];
case _CardKind.bookingList:
return const <String>['未确认的预订', '预订详情'];
case _CardKind.orderList:
return const <String>['支付状态', '指定桌位订单'];
case _CardKind.dishRank:
return const <String>['销量排行', '销售额排行', '滞销菜品'];
case _CardKind.reservationDraft:
case _CardKind.plain:
return const <String>[];
}
}
_AgentCard _withFollowUps(_AgentCard card) {
return _AgentCard(
kind: card.kind,
title: card.title,
items: card.items,
followUps: _followUpsFor(card.kind),
quickActions: card.quickActions,
);
}
bool _usingCloudAsr = false;
bool _whisperModelReady = false;
bool _voicePressActive = false;
... ... @@ -132,16 +624,99 @@ class _AgentChatPageState extends State<AgentChatPage> {
return model.user?.sellerid ?? 'unknown';
}
AgentCredentials? _agentCredentials() {
final User? user = Provider.of<UserModel>(context, listen: false).user;
if (user == null) return null;
final int? uid = int.tryParse(user.openid ?? '');
final int? sellerId = int.tryParse(user.sellerid ?? '');
final String loginToken = user.token ?? '';
if (uid == null || sellerId == null || loginToken.isEmpty) return null;
final String deviceName = switch (defaultTargetPlatform) {
TargetPlatform.android => 'android-main',
TargetPlatform.iOS => 'ios-main',
_ => 'desktop-main',
};
return AgentCredentials(
uid: uid,
sellerId: sellerId,
loginToken: loginToken,
deviceName: deviceName,
);
}
Future<void> _prepareAgentSession() async {
try {
final AgentCredentials? credentials = _agentCredentials();
if (credentials == null) return;
await _agentApi.prepare(credentials);
await _ensureAgentConversation();
} on AgentApiException catch (error) {
if (error.statusCode == 401) {
_handleAgentAuthExpired();
} else {
debugPrint('Agent session prepare skipped: $error');
}
} catch (error) {
debugPrint('Agent session prepare skipped: $error');
}
}
void _handleAgentAuthExpired() {
if (!mounted || _agentAuthExpiredHandling) return;
_agentAuthExpiredHandling = true;
_agentConversationId = null;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('App 登录已过期,请重新登录')),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) NavigatorUtil.goLoginPage(context);
});
}
Future<String> _ensureAgentConversation({CancelToken? cancelToken}) async {
final String? current = _agentConversationId;
if (current != null && current.isNotEmpty) return current;
final AgentConversation conversation =
await _agentApi.createConversation(cancelToken: cancelToken);
_agentConversationId = conversation.id;
return conversation.id;
}
Future<void> _disposeAgentAudioRecorder(
AudioRecorder? recorder, {
bool cancelFirst = false,
}) async {
if (recorder == null) return;
if (cancelFirst) {
try {
await recorder.cancel();
} catch (_) {}
}
try {
await recorder.dispose();
} catch (_) {}
}
void _deleteAgentVoiceFile(String? path) {
if (path == null || path.isEmpty) return;
final File file = File(path);
if (file.existsSync()) {
try {
file.deleteSync();
} catch (_) {}
}
}
@override
void initState() {
super.initState();
Utils.changeStatusColor(color: MyColours.color_199095);
if (!_supportsWhisper) {
_initSpeech();
} else {
unawaited(_prewarmWhisperEngine());
}
_loadHistory();
unawaited(_prepareAgentSession());
_initSharedReservationListener();
}
... ... @@ -159,6 +734,14 @@ class _AgentChatPageState extends State<AgentChatPage> {
}
unawaited(_whisperTask?.cancel());
unawaited(_cloudAsrSession?.cancel());
_agentCancelToken?.cancel();
_deleteAgentVoiceFile(_agentAudioPath);
unawaited(_disposeAgentAudioRecorder(
_agentAudioRecorder,
cancelFirst: true,
));
_agentAudioRecorder = null;
_agentAudioPath = null;
try {
_whisperEngine?.dispose();
} catch (_) {}
... ... @@ -258,6 +841,12 @@ class _AgentChatPageState extends State<AgentChatPage> {
title: json['title']?.toString() ?? '',
items: (json['items'] as List? ?? const <dynamic>[])
.cast<Map<String, dynamic>>(),
followUps: (json['followUps'] as List? ?? const <dynamic>[])
.whereType<String>()
.toList(),
quickActions: (json['quickActions'] as List? ?? const <dynamic>[])
.whereType<String>()
.toList(),
);
}
... ... @@ -273,6 +862,8 @@ class _AgentChatPageState extends State<AgentChatPage> {
return _CardKind.orderList;
case 'dish_rank':
return _CardKind.dishRank;
case 'reservation_draft':
return _CardKind.reservationDraft;
default:
return _CardKind.plain;
}
... ... @@ -290,6 +881,8 @@ class _AgentChatPageState extends State<AgentChatPage> {
return 'order_list';
case _CardKind.dishRank:
return 'dish_rank';
case _CardKind.reservationDraft:
return 'reservation_draft';
case _CardKind.plain:
return 'plain';
}
... ... @@ -336,25 +929,48 @@ class _AgentChatPageState extends State<AgentChatPage> {
});
}
void _send(String rawText) {
void _send(String rawText, {String inputType = 'text'}) {
final String text = rawText.trim();
if (text.isEmpty || _streaming) return;
if (_pendingReservation != null) {
_handleReservationClarification(text);
_inputController.clear();
FocusScope.of(context).unfocus();
return;
}
if (_maybeEnterReservationFlow(text)) {
_inputController.clear();
FocusScope.of(context).unfocus();
return;
}
_sendAgentMessage(text, inputType: inputType);
}
void _sendAgentMessage(
String text, {
String inputType = 'text',
String? clientMessageId,
}) {
final String messageId = clientMessageId ?? _newClientMessageId();
final _ChatMessage userMessage = _ChatMessage(
role: _Role.user,
text: text,
clientMessageId: messageId,
inputType: inputType,
);
final _ChatMessage agentMessage = _ChatMessage(
role: _Role.agent,
text: '',
status: _MessageStatus.sending,
);
setState(() {
_messages.add(_ChatMessage(role: _Role.user, text: text));
_messages.add(
_ChatMessage(
role: _Role.agent,
text: '',
status: _MessageStatus.sending,
),
);
_messages
..add(userMessage)
..add(agentMessage);
_streaming = true;
_stopRequested = false;
_voiceStage = _VoiceStage.idle;
... ... @@ -365,11 +981,148 @@ class _AgentChatPageState extends State<AgentChatPage> {
_scrollToBottom();
_persist();
final _MockReply reply = _mockReply(text);
Future.delayed(const Duration(milliseconds: 450), () {
if (!mounted) return;
_startTypewriter(reply);
unawaited(_requestAgentReply(
text: text,
inputType: inputType,
clientMessageId: messageId,
agentMessage: agentMessage,
));
}
String _newClientMessageId() {
return 'app-msg-${DateTime.now().microsecondsSinceEpoch}-${_messages.length}';
}
Future<void> _requestAgentReply({
required String text,
required String inputType,
required String clientMessageId,
required _ChatMessage agentMessage,
}) async {
final CancelToken cancelToken = CancelToken();
_agentCancelToken = cancelToken;
try {
final AgentCredentials? credentials = _agentCredentials();
if (credentials == null) {
throw const AgentApiException(0, '未获取到登录信息,请重新登录');
}
await _agentApi.prepare(credentials);
String conversationId = await _ensureAgentConversation(
cancelToken: cancelToken,
);
AgentMessageData data;
try {
data = await _agentApi.sendMessage(
conversationId: conversationId,
content: text,
inputType: inputType,
clientMessageId: clientMessageId,
cancelToken: cancelToken,
);
} on AgentApiException catch (error) {
if (error.statusCode != 404) rethrow;
_agentConversationId = null;
conversationId = await _ensureAgentConversation(
cancelToken: cancelToken,
);
data = await _agentApi.sendMessage(
conversationId: conversationId,
content: text,
inputType: inputType,
clientMessageId: clientMessageId,
cancelToken: cancelToken,
);
}
final String reply = data.content.trim();
if (reply.isEmpty) {
throw const AgentApiException(0, 'AI 没有返回内容,请重试');
}
if (!mounted || cancelToken.isCancelled) return;
_startTypewriter(_MockReply(reply));
} on AgentApiException catch (error) {
if (error.statusCode == 401) {
_handleAgentAuthExpired();
}
_finishAgentRequestError(agentMessage, error.message);
} on DioException catch (error) {
if (cancelToken.isCancelled || error.type == DioExceptionType.cancel) {
_finishAgentRequestStopped(agentMessage);
} else {
_finishAgentRequestError(agentMessage, '网络请求失败,请稍后重试');
}
} catch (_) {
_finishAgentRequestError(agentMessage, '请求失败,请稍后重试');
} finally {
if (identical(_agentCancelToken, cancelToken)) {
_agentCancelToken = null;
}
}
}
void _finishAgentRequestError(_ChatMessage agentMessage, String message) {
if (!mounted) return;
setState(() {
agentMessage.text = message.isEmpty ? '请求失败,请稍后重试' : message;
agentMessage.card = null;
agentMessage.status = _MessageStatus.error;
_streaming = false;
_stopRequested = false;
});
_scrollToBottom();
_persist();
}
void _finishAgentRequestStopped(_ChatMessage agentMessage) {
if (!mounted) return;
setState(() {
final String current = agentMessage.text.trimRight();
agentMessage.text = current.isEmpty ? '已停止。' : '$current (已停止)';
agentMessage.status = _MessageStatus.done;
_streaming = false;
_stopRequested = false;
});
_scrollToBottom();
_persist();
}
void _retryAgentMessage(int agentIndex) {
if (_streaming || agentIndex <= 0 || agentIndex >= _messages.length) {
return;
}
final _ChatMessage agentMessage = _messages[agentIndex];
if (agentMessage.role != _Role.agent ||
agentMessage.status != _MessageStatus.error) {
return;
}
_ChatMessage? userMessage;
for (int index = agentIndex - 1; index >= 0; index--) {
if (_messages[index].role == _Role.user) {
userMessage = _messages[index];
break;
}
}
if (userMessage == null) return;
final String messageId =
userMessage.clientMessageId ?? _newClientMessageId();
setState(() {
agentMessage.text = '';
agentMessage.card = null;
agentMessage.status = _MessageStatus.sending;
_streaming = true;
_stopRequested = false;
});
_persist();
unawaited(_requestAgentReply(
text: userMessage.text,
inputType: userMessage.inputType,
clientMessageId: messageId,
agentMessage: agentMessage,
));
}
bool _maybeEnterReservationFlow(String text) {
... ... @@ -380,7 +1133,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
ReservationImageParser.parse(trimmed);
if (!_isReservationText(result)) return false;
unawaited(_showReservationConfirm(_normalizeReservationDate(result)));
_startReservationConversation(_normalizeReservationDate(result));
return true;
}
... ... @@ -443,6 +1196,10 @@ class _AgentChatPageState extends State<AgentChatPage> {
void _stop() {
_stopRequested = true;
final CancelToken? cancelToken = _agentCancelToken;
if (cancelToken != null && !cancelToken.isCancelled) {
cancelToken.cancel('用户停止');
}
}
bool _containsAny(String q, List<String> keywords) {
... ... @@ -853,7 +1610,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceTimer?.cancel();
if (_supportsWhisper) {
await _startCloudVoice(requestId);
await _startAgentVoice(requestId);
return;
}
... ... @@ -861,6 +1618,151 @@ class _AgentChatPageState extends State<AgentChatPage> {
await _startLegacyVoice();
}
/// 使用 Agent 服务录音转写;录音文件只发送到 Agent 服务,不再调用百炼 ASR。
Future<void> _startAgentVoice(int requestId) async {
setState(() {
_voiceStage = _VoiceStage.preparing;
_voiceSeconds = 0;
_recognizedText = '';
_voiceHint = '正在准备录音…';
});
final AudioRecorder recorder = AudioRecorder();
_agentAudioRecorder = recorder;
try {
if (!await recorder.hasPermission()) {
final PermissionStatus status = await Permission.microphone.request();
if (!status.isGranted) {
throw const AgentApiException(0, '未获得麦克风权限');
}
}
final Directory directory = await getTemporaryDirectory();
final String path =
'${directory.path}/agent_voice_${DateTime.now().microsecondsSinceEpoch}.wav';
await recorder.start(
const RecordConfig(
encoder: AudioEncoder.wav,
sampleRate: 16000,
numChannels: 1,
),
path: path,
);
_agentAudioPath = path;
if (!mounted || requestId != _voiceRequestId) {
await _cancelAgentVoice(dispose: true);
return;
}
if (!_voicePressActive) {
await _cancelAgentVoice(dispose: true);
return;
}
setState(() {
_voiceStage = _VoiceStage.recording;
_voiceSeconds = 0;
_recognizedText = '';
_voiceHint = '';
});
_voiceTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _voiceSeconds++);
});
} catch (error) {
debugPrint('Agent voice recording start failed: $error');
await _cancelAgentVoice(dispose: true);
if (mounted) {
_resetVoiceAfterError('无法启动录音,请检查麦克风权限');
}
}
}
Future<void> _stopAgentVoice(int requestId) async {
_voiceTimer?.cancel();
final AudioRecorder? recorder = _agentAudioRecorder;
final String? recordedPath = _agentAudioPath;
_agentAudioRecorder = null;
_agentAudioPath = null;
if (recorder == null) return;
String? path;
try {
path = await recorder.stop();
} catch (_) {}
await _disposeAgentAudioRecorder(recorder);
final String finalPath = path ?? recordedPath ?? '';
if (!mounted || requestId != _voiceRequestId) {
_deleteAgentVoiceFile(finalPath);
return;
}
if (finalPath.isEmpty || !File(finalPath).existsSync()) {
_resetVoiceAfterError('没有录制到语音,请重试');
return;
}
setState(() {
_voiceStage = _VoiceStage.preparing;
_voiceSeconds = 0;
_recognizedText = '正在转写…';
_voiceHint = '正在转写语音…';
});
try {
final AgentCredentials? credentials = _agentCredentials();
if (credentials == null) {
throw const AgentApiException(0, '未获取到登录信息,请重新登录');
}
await _agentApi.prepare(credentials);
final String mimeType = lookupMimeType(finalPath) ?? 'audio/wav';
final String text = await _agentApi.transcribeAudio(
filePath: finalPath,
fileName: finalPath.split('/').last,
mimeType: mimeType,
);
_deleteAgentVoiceFile(finalPath);
if (!mounted || requestId != _voiceRequestId) return;
if (text.isEmpty) {
_resetVoiceAfterError('没有识别到内容,请重说');
return;
}
await _finishVoiceWithText(text, isVoice: true);
} on AgentApiException catch (error) {
_deleteAgentVoiceFile(finalPath);
if (mounted && requestId == _voiceRequestId) {
_resetVoiceAfterError(error.message);
}
} catch (error) {
debugPrint('Agent voice transcription failed: $error');
_deleteAgentVoiceFile(finalPath);
if (mounted && requestId == _voiceRequestId) {
_resetVoiceAfterError('语音转写失败,请重试');
}
}
}
Future<void> _cancelAgentVoice({bool dispose = false}) async {
_voicePressActive = false;
_voiceTimer?.cancel();
_voiceRequestId++;
final AudioRecorder? recorder = _agentAudioRecorder;
final String? path = _agentAudioPath;
_agentAudioRecorder = null;
_agentAudioPath = null;
if (recorder != null) {
try {
await recorder.cancel();
} catch (_) {}
if (dispose) {
try {
await recorder.dispose();
} catch (_) {}
}
}
_deleteAgentVoiceFile(path);
}
/// 移动端优先使用百炼 Paraformer 实时语音识别,失败时回退到离线 Whisper。
Future<void> _startCloudVoice(int requestId) async {
_usingCloudAsr = true;
... ... @@ -1251,6 +2153,12 @@ class _AgentChatPageState extends State<AgentChatPage> {
if (!mounted) return;
if (_voiceStage != _VoiceStage.recording) return;
if (_agentAudioRecorder != null) {
final int requestId = _voiceRequestId;
await _stopAgentVoice(requestId);
return;
}
if (_usingCloudAsr) {
final int requestId = _voiceRequestId;
await _stopCloudVoice(requestId);
... ... @@ -1314,7 +2222,10 @@ class _AgentChatPageState extends State<AgentChatPage> {
}
}
Future<void> _finishVoiceWithText(String? text) async {
Future<void> _finishVoiceWithText(
String? text, {
bool isVoice = true,
}) async {
_voicePressActive = false;
_voiceTimer?.cancel();
_usingNativeSpeech = false;
... ... @@ -1346,6 +2257,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
setState(() {
_voiceStage = _VoiceStage.confirming;
_confirmText = simplified;
_nextMessageIsVoice = isVoice;
});
}
... ... @@ -1356,6 +2268,13 @@ class _AgentChatPageState extends State<AgentChatPage> {
_usingWhisper = false;
_usingNativeSpeech = false;
_usingCloudAsr = false;
_nextMessageIsVoice = false;
final AudioRecorder? recorder = _agentAudioRecorder;
final String? voicePath = _agentAudioPath;
_agentAudioRecorder = null;
_agentAudioPath = null;
unawaited(_disposeAgentAudioRecorder(recorder, cancelFirst: true));
_deleteAgentVoiceFile(voicePath);
unawaited(_cloudAsrSession?.cancel());
_cloudAsrSession = null;
_whisperUpdateSub?.cancel();
... ... @@ -1378,6 +2297,13 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceTimer?.cancel();
_voiceRequestId++;
_usingWhisper = false;
_nextMessageIsVoice = false;
final AudioRecorder? recorder = _agentAudioRecorder;
final String? voicePath = _agentAudioPath;
_agentAudioRecorder = null;
_agentAudioPath = null;
unawaited(_disposeAgentAudioRecorder(recorder, cancelFirst: true));
_deleteAgentVoiceFile(voicePath);
if (_usingCloudAsr) {
_usingCloudAsr = false;
unawaited(_cloudAsrSession?.cancel());
... ... @@ -1395,6 +2321,18 @@ class _AgentChatPageState extends State<AgentChatPage> {
_voiceTimer?.cancel();
_voiceRequestId++;
_usingWhisper = false;
_nextMessageIsVoice = false;
if (_agentAudioRecorder != null) {
await _cancelAgentVoice();
if (mounted) {
setState(() {
_voiceStage = _VoiceStage.idle;
_recognizedText = '';
_voiceHint = '';
});
}
return;
}
if (_usingCloudAsr) {
_usingCloudAsr = false;
final ParaformerAsrSession? session = _cloudAsrSession;
... ... @@ -1434,8 +2372,10 @@ class _AgentChatPageState extends State<AgentChatPage> {
void _confirmVoice() {
final String text = _confirmText.trim();
final String inputType = _nextMessageIsVoice ? 'voice' : 'text';
_nextMessageIsVoice = false;
setState(() => _voiceStage = _VoiceStage.idle);
if (text.isNotEmpty) _send(text);
if (text.isNotEmpty) _send(text, inputType: inputType);
}
Future<void> _pickReservationImage() async {
... ... @@ -1489,6 +2429,20 @@ class _AgentChatPageState extends State<AgentChatPage> {
Future<void> _processReservationImageBytes(Uint8List bytes) async {
if (_imageProcessing) return;
setState(() => _imageProcessing = true);
// 保存图片到临时目录,以便在会话气泡中展示。
String? savedPath;
try {
final Directory dir = await getTemporaryDirectory();
final String fileName =
'reservation_${DateTime.now().millisecondsSinceEpoch}.jpg';
final File f = File('${dir.path}/$fileName');
await f.writeAsBytes(bytes);
savedPath = f.path;
} catch (_) {
// 无关紧要,识别继续。
}
try {
final QwenOcrClient ocr = QwenOcrClient(apiKey: _qwenApiKey);
final ReservationImageParseResult result =
... ... @@ -1496,32 +2450,73 @@ class _AgentChatPageState extends State<AgentChatPage> {
debugPrint('千问OCR识别成功: ${result.rawText.length}字');
if (!mounted) return;
setState(() => _imageProcessing = false);
if (!result.hasAny) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('没有从图片中识别到预订信息')),
);
// 没识别到任何字段,以会话形式告知用户。
setState(() {
_messages.add(_ChatMessage(
role: _Role.user,
text: '我发来一张图片',
imagePath: savedPath,
));
_messages.add(_ChatMessage(
role: _Role.agent,
text: '抱歉,我没有从这张图片中识别到预订信息。\n'
'请确认图片是否包含预订人、电话、日期和人数等信息,'
'也可以直接文字告诉我哦~',
));
});
_scrollToBottom();
_persist();
if (savedPath != null && File(savedPath).existsSync()) {
try {
await File(savedPath).delete();
} catch (_) {}
}
return;
}
setState(() => _imageProcessing = false);
await _showReservationConfirm(_normalizeReservationDate(result));
// 走会话式预订流程,不弹窗。
_startReservationConversation(
_normalizeReservationDate(result),
imagePath: savedPath,
);
} on QwenOcrException catch (error) {
debugPrint('OCR失败: ${error.toString()}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.message)),
);
setState(() => _imageProcessing = false);
setState(() {
_messages.add(_ChatMessage(
role: _Role.user,
text: '我发来一张图片',
imagePath: savedPath,
));
_messages.add(_ChatMessage(
role: _Role.agent,
text: '识别图片时出了点问题:${error.message}\n换个图片试试,或者直接文字告诉我预订信息~',
));
});
_scrollToBottom();
_persist();
}
} catch (error) {
debugPrint('OCR异常: $error');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('图片识别失败,请重试')),
);
}
} finally {
if (mounted && _imageProcessing) {
setState(() => _imageProcessing = false);
setState(() {
_messages.add(_ChatMessage(
role: _Role.user,
text: '我发来一张图片',
imagePath: savedPath,
));
_messages.add(_ChatMessage(
role: _Role.agent,
text: '图片识别失败,请换个图片重试,或者直接文字告诉我预订信息~',
));
});
_scrollToBottom();
_persist();
}
}
}
... ... @@ -1826,6 +2821,8 @@ class _AgentChatPageState extends State<AgentChatPage> {
return _MessageBubble(
message: _messages[index],
themeColor: themeColor,
onSuggestion: _send,
onRetry: () => _retryAgentMessage(index),
);
},
),
... ... @@ -1869,6 +2866,7 @@ class _AgentChatPageState extends State<AgentChatPage> {
initialText: _confirmText,
themeColor: themeColor,
onCancel: () {
_nextMessageIsVoice = false;
setState(() => _voiceStage = _VoiceStage.idle);
},
onConfirm: _confirmVoice,
... ... @@ -2280,10 +3278,17 @@ class _ReservationConfirmSheetState extends State<_ReservationConfirmSheet> {
}
class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message, required this.themeColor});
const _MessageBubble({
required this.message,
required this.themeColor,
this.onSuggestion,
this.onRetry,
});
final _ChatMessage message;
final Color themeColor;
final ValueChanged<String>? onSuggestion;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
... ... @@ -2293,68 +3298,113 @@ class _MessageBubble extends StatelessWidget {
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 5),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.82,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(14),
topRight: const Radius.circular(14),
bottomLeft: Radius.circular(isUser ? 14 : 3),
bottomRight: Radius.circular(isUser ? 3 : 14),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: message.status == _MessageStatus.error ? onRetry : null,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 5),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.82,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(14),
topRight: const Radius.circular(14),
bottomLeft: Radius.circular(isUser ? 14 : 3),
bottomRight: Radius.circular(isUser ? 3 : 14),
),
boxShadow: isUser
? null
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
boxShadow: isUser
? null
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 6,
offset: const Offset(0, 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.imagePath != null &&
File(message.imagePath!).existsSync()) ...[
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(File(message.imagePath!),
width: 180, height: 160, fit: BoxFit.cover),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildContent(textColor),
if (message.card != null) ...[
const SizedBox(height: 8),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _AgentCardDetailPage(
card: message.card!,
themeColor: themeColor,
),
],
_buildContent(textColor),
if (message.card != null) ...[
const SizedBox(height: 8),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _AgentCardDetailPage(
card: message.card!,
themeColor: themeColor,
),
),
),
);
},
child: _CardView(card: message.card!, themeColor: themeColor),
),
],
if (message.status == _MessageStatus.error)
const Padding(
padding: EdgeInsets.only(top: 6),
child: Text(
'发送失败,点击重发',
style: TextStyle(fontSize: 11, color: MyColours.color_E34D59),
);
},
child: _CardView(
card: message.card!,
themeColor: themeColor,
onAction: onSuggestion),
),
],
if (message.card != null &&
message.status == _MessageStatus.done &&
message.card!.followUps.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: message.card!.followUps.map((String item) {
return GestureDetector(
onTap: () => onSuggestion?.call(item),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: themeColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: themeColor.withValues(alpha: 0.18)),
),
child: Text(item,
style: TextStyle(fontSize: 12, color: themeColor)),
),
);
}).toList(),
),
],
if (message.status == _MessageStatus.error)
const Padding(
padding: EdgeInsets.only(top: 6),
child: Text(
'发送失败,点击重发',
style:
TextStyle(fontSize: 11, color: MyColours.color_E34D59),
),
),
const SizedBox(height: 4),
Text(
isUser ? _timeText(context) : '经营助手 · ${_timeText(context)}',
style: TextStyle(
fontSize: 10,
color: isUser ? Colors.white70 : MyColours.color_999999,
),
),
const SizedBox(height: 4),
Text(
isUser ? _timeText(context) : '经营助手 · ${_timeText(context)}',
style: TextStyle(
fontSize: 10,
color: isUser ? Colors.white70 : MyColours.color_999999,
),
),
],
],
),
),
),
);
... ... @@ -2440,10 +3490,12 @@ class _CaretState extends State<_Caret> with SingleTickerProviderStateMixin {
}
class _CardView extends StatelessWidget {
const _CardView({required this.card, required this.themeColor});
const _CardView(
{required this.card, required this.themeColor, this.onAction});
final _AgentCard card;
final Color themeColor;
final ValueChanged<String>? onAction;
@override
Widget build(BuildContext context) {
... ... @@ -2467,15 +3519,47 @@ class _CardView extends StatelessWidget {
),
const SizedBox(height: 8),
_buildItems(),
if (card.quickActions.isNotEmpty && onAction != null) ...[
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 6,
children: card.quickActions.map((String action) {
final bool isPrimary =
action.contains('确认') || action.contains('好了');
return GestureDetector(
onTap: () => onAction!.call(action),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isPrimary
? themeColor
: themeColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(16),
border: isPrimary
? null
: Border.all(
color: themeColor.withValues(alpha: 0.25)),
),
child: Text(action,
style: TextStyle(
fontSize: 12,
fontWeight:
isPrimary ? FontWeight.w600 : FontWeight.w400,
color: isPrimary ? Colors.white : themeColor)),
),
);
}).toList(),
),
],
if (card.kind != _CardKind.plain) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'点击查看详情',
style: TextStyle(fontSize: 10, color: themeColor),
),
Text('点击查看详情',
style: TextStyle(fontSize: 10, color: themeColor)),
const SizedBox(width: 2),
Icon(Icons.chevron_right, size: 12, color: themeColor),
],
... ... @@ -2528,6 +3612,30 @@ class _CardView extends StatelessWidget {
);
}).toList(),
);
case _CardKind.reservationDraft:
return Column(
children: card.items.map((Map<String, dynamic> item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${item['label'] ?? ''}',
style: const TextStyle(
fontSize: 11, color: MyColours.color_999999)),
const SizedBox(width: 10),
Expanded(
child: Text('${item['value'] ?? ''}',
textAlign: TextAlign.right,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF303030)))),
],
),
);
}).toList(),
);
case _CardKind.dishRank:
return Column(
children: card.items
... ... @@ -2797,6 +3905,14 @@ class _AgentCardDetailPage extends StatelessWidget {
onTap: () => _openOrderDetail(context, '#2026091720'),
),
];
case _CardKind.reservationDraft:
return card.items.map((Map<String, dynamic> item) {
return _DetailRow(
'${item['label'] ?? ''}',
'${item['value'] ?? ''}',
color: themeColor,
);
}).toList();
case _CardKind.dishRank:
final List<_DetailRow> rows = <_DetailRow>[];
for (final Map<String, dynamic> item in card.items) {
... ...
/// 经营助手“回复数据源”统一契约。
///
/// 后续接入后台接口时,实现 [AgentReplyService] 并返回 [AgentReplyOutput],
/// 其中 `card` 使用与本地会话持久化一致的 JSON 结构(kind/title/items/followUps),
/// 页面即可无缝切换为后台真实数据,无需改动 UI 层。
class AgentReplyTurn {
const AgentReplyTurn({required this.role, required this.content});
/// user 或 assistant。
final String role;
final String content;
}
class AgentReplyRequest {
const AgentReplyRequest({
required this.storeId,
required this.storeName,
required this.question,
required this.history,
});
final String storeId;
final String storeName;
final String question;
/// 最近几轮对话,用于连续追问。
final List<AgentReplyTurn> history;
}
class AgentReplyOutput {
const AgentReplyOutput({
required this.intent,
required this.text,
this.card,
this.followUps = const <String>[],
});
/// 意图标识,便于日志与后续埋点。
final String intent;
/// 展示给用户的一句话/一段话回复。
final String text;
/// 结构化业务卡片,字段与 [AgentChatStore] 持久化格式一致:
/// `{kind, title, items, followUps}`。
final Map<String, dynamic>? card;
final List<String> followUps;
}
/// 后台回复服务接口(预留)。
///
/// 现阶段由页面内置的本地模拟逻辑兜底;后续实现
/// `BackendAgentReplyService` 调用门店经营接口即可替换。
abstract class AgentReplyService {
Future<AgentReplyOutput> reply(AgentReplyRequest request);
}
... ...
import 'dart:convert';
import 'package:dio/dio.dart';
/// 阿里云百炼千问文本模型返回的意图识别结果。
class AgentIntentResult {
const AgentIntentResult({
required this.intent,
required this.reply,
required this.entities,
});
/// 意图标识,如 revenue、empty_tables、recent_orders、chat 等。
final String intent;
/// 面向用户的一句话回复,要求自然、口语化。
final String reply;
/// 意图附带的结构化实体,如 party、table、order_no、time_range。
final Map<String, dynamic> entities;
}
class QwenChatException implements Exception {
const QwenChatException(this.code, this.message);
final Object code;
final String message;
@override
String toString() => 'QwenChatException($code, $message)';
}
/// 经营助手对话大脑:用千问做自然语言理解、意图识别与友好回复。
///
/// 结构化业务卡片仍在 App 本地生成,LLM 只负责“理解用户 + 说人话”,
/// 这样既能贴近豆包式的自然对话体验,又能保证数据卡片稳定可交互。
class QwenChatClient {
QwenChatClient({
required this.apiKey,
this.model = 'qwen-plus',
});
final String apiKey;
final String model;
static const String _endpoint =
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
final Dio _dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 30),
responseType: ResponseType.json,
),
);
static const List<String> intents = <String>[
'revenue', // 营业额/经营概览
'order_count', // 订单量
'avg_spend', // 人均消费
'compare', // 同比环比
'period_stats', // 指定时间段统计
'empty_tables', // 当前空桌
'tonight_tables', // 今晚空桌
'table_by_party', // 指定人数可用桌位
'table_status', // 桌位状态
'today_bookings', // 今日预订
'recent_bookings', // 近期预订
'confirmed_bookings', // 已确认预订
'unconfirmed_bookings', // 未确认预订
'booking_detail', // 预订详情
'recent_orders', // 近期订单
'order_by_no', // 指定订单
'order_by_table', // 指定桌位订单
'pay_status', // 支付状态
'hot_dishes', // 热销菜品
'dish_sales_rank', // 销量排行
'dish_amount_rank', // 销售额排行
'slow_dishes', // 滞销菜品
'booking', // 用户想录入一条预订
'chat', // 闲聊或无法归类
];
String _systemPrompt(String storeName, String today) {
return '你是「$storeName」餐厅的 AI 经营助手,名字叫“小周”,语气专业但亲切、像靠谱的店长助理。'
'今天是 $today。'
'你能帮老板查询经营数据、桌位情况、预订情况、订单查询和菜品销售情况,并支持连续追问(结合上文继承门店、日期和查询条件)。\n\n'
'你的能力范围:\n'
'1. 经营数据:营业额、订单量、人均消费、指定时间段统计、同比环比;\n'
'2. 桌位情况:当前空桌、今晚空桌、指定人数可用桌位、桌位状态;\n'
'3. 预订情况:今日/近期预订、已确认、未确认、预订详情;\n'
'4. 订单查询:近期订单、指定订单、指定桌位订单、支付状态;\n'
'5. 菜品销售:热销菜品、销量排行、销售额排行、滞销菜品。\n\n'
'请结合对话历史理解用户的连续追问(例如用户先问“今天营业额”,再说“那昨天呢”,应理解为查昨天营业额)。'
'只输出一个 JSON 对象,不要输出任何解释、Markdown 代码块或多余文字。\n\n'
'JSON 字段:\n'
'- intent: 用户意图,必须是下面列表中的一个值;\n'
'${intents.map((String i) => '- $i').join('\n')}\n'
'- reply: 给用户的一句话回复。要自然、口语化,像真人在回复;业务类回复请包含合理的具体数字和一句可执行的建议;语气不要像客服模板。\n'
'- entities: 提取到的实体对象,字段可为 party(人数,数字)、table(桌号/包间号)、order_no(订单号)、time_range(如“今天/今晚/昨天/近7天”)。没有则为空对象。\n\n'
'输出示例:\n'
'{"intent":"tonight_tables","reply":"今晚 20 点大概还有 8 张空桌,其中 2 桌是待确认状态,建议有客人先锁 A12 或 C03。","entities":{"time_range":"今晚"}}';
}
/// 调用千问进行意图识别与回复生成。
///
/// [history] 为早前的对话(role: user/assistant,content: 文本)。
/// 失败时抛出 [QwenChatException]。
Future<AgentIntentResult> chat({
required String storeName,
required List<Map<String, String>> history,
required String question,
}) async {
final DateTime now = DateTime.now();
final String today = '${now.year}-${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
final List<Map<String, String>> messages = <Map<String, String>>[
<String, String>{
'role': 'system',
'content': _systemPrompt(storeName, today)
},
...history,
<String, String>{'role': 'user', 'content': question},
];
final Response<Map<String, dynamic>> response;
try {
response = await _dio.post<Map<String, dynamic>>(
_endpoint,
data: <String, dynamic>{
'model': model,
'temperature': 0.5,
'messages': messages,
},
options: Options(
headers: <String, String>{
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
),
);
} on DioException catch (error) {
throw QwenChatException(
error.response?.statusCode ?? 0,
_friendlyDioError(error),
);
}
final Map<String, dynamic>? data = response.data;
if (data == null) {
throw const QwenChatException(0, '对话返回数据异常');
}
final dynamic error = data['error'];
if (error is Map) {
throw QwenChatException(
error['code']?.toString() ?? '0',
error['message']?.toString() ?? '对话请求失败',
);
}
final List<dynamic>? choices = data['choices'] as List<dynamic>?;
final dynamic message = choices != null && choices.isNotEmpty
? (choices.first as Map<String, dynamic>)['message']
: null;
if (message is! Map) {
throw const QwenChatException(0, '未返回有效回复');
}
final String content = (message['content'] ?? '').toString().trim();
final Map<String, dynamic>? json = _parseJsonObject(content);
if (json == null) {
// 模型没按要求返回 JSON 时,直接把内容当闲聊回复。
return AgentIntentResult(
intent: 'chat',
reply: content,
entities: const <String, dynamic>{},
);
}
return AgentIntentResult(
intent: (json['intent'] ?? 'chat').toString().trim().toLowerCase(),
reply: (json['reply'] ?? content).toString().trim(),
entities: json['entities'] is Map
? Map<String, dynamic>.from(json['entities'] as Map)
: const <String, dynamic>{},
);
}
Map<String, dynamic>? _parseJsonObject(String content) {
final String trimmed = content.trim();
if (trimmed.isEmpty) return null;
final int start = trimmed.indexOf('{');
final int end = trimmed.lastIndexOf('}');
if (start < 0 || end <= start) return null;
try {
final dynamic decoded = jsonDecode(trimmed.substring(start, end + 1));
return decoded is Map<String, dynamic> ? decoded : null;
} catch (_) {
return null;
}
}
String _friendlyDioError(DioException error) {
final int? statusCode = error.response?.statusCode;
if (statusCode == 401) return '千问 API Key 无效,请检查百炼密钥';
if (statusCode == 429) return '请求太频繁或额度不足,请稍后重试';
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return '对话请求超时,请检查网络后重试';
}
return '对话网络请求失败,请检查网络';
}
}
... ...
... ... @@ -11,6 +11,7 @@ import 'package:one_key/models/user.dart';
import 'package:one_key/net/base_resp.dart';
import 'package:one_key/net/dio_util.dart';
import 'package:one_key/net/http_url.dart';
import 'package:one_key/pages/agent/agent_api_client.dart';
import 'package:one_key/res/colors.dart';
import 'package:one_key/route/navigator_util.dart';
... ... @@ -415,6 +416,7 @@ class UserModel with ChangeNotifier {
}
App.sp!.setBool("isLogin", false);
unawaited(AgentApiClient.instance.logout());
// XgFlutterPlugin().stopXg();
NavigatorUtil.goLoginPage(context);
}
... ... @@ -436,6 +438,7 @@ class UserModel with ChangeNotifier {
Utils.showToast(baseResp.msg ?? "切换店铺失败,请稍后重试");
return false;
} else {
unawaited(AgentApiClient.instance.logout());
// XgFlutterPlugin().stopXg();
return true;
}
... ...