Files
xianyan/lib/features/discover/models/chat_message.dart
Developer ae6804e8bd refactor: 兼容后端返回数字类型波动,清理废弃代码
主要变更:
1.  全局修复类型转换问题,将多处`as int?`改为`(num?)?.toInt()`兼容浮点/字符串类型的数字字段
2.  移除废弃的nearby_p2p配对方式和对应的依赖包
3.  优化鸿蒙端快捷方式、引导页、路由导航的稳定性
4.  合并日志输出避免鸿蒙端IDE卡顿
5.  修复安卓端蓝牙权限冗余声明
2026-06-07 08:04:38 +08:00

425 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 闲言APP — 会话消息模型
// 创建时间: 2026-04-30
// 更新时间: 2026-05-15
// 作用: 会话流消息数据模型支持AI推送+用户消息+导入导出+Drift互转
// 上次更新: E9新增tags便捷访问方法(getTags/addTag/removeTag)
// ============================================================
import 'dart:convert';
import 'package:xianyan/core/storage/database/app_database.dart';
enum ChatMessageType {
sentence('sentence', '发现句子'),
greeting('greeting', '问候推送'),
weather('weather', '天气推送'),
scenario('scenario', '情景推送'),
system('system', '系统消息'),
userMessage('user_message', '用户消息'),
image('image', '图片消息'),
file('file', '文件消息'),
audio('audio', '语音消息'),
video('video', '视频消息'),
richText('rich_text', '富文本消息'),
link('link', '链接消息'),
document('document', '文档消息'),
readlaterSentence('readlater_sentence', '稍后读句子');
const ChatMessageType(this.id, this.label);
final String id;
final String label;
static ChatMessageType fromId(String id) {
return ChatMessageType.values.firstWhere(
(t) => t.id == id,
orElse: () => ChatMessageType.sentence,
);
}
}
enum ChatMessageRole {
assistant('assistant'),
user('user');
const ChatMessageRole(this.id);
final String id;
static ChatMessageRole fromId(String id) {
return ChatMessageRole.values.firstWhere(
(r) => r.id == id,
orElse: () => ChatMessageRole.assistant,
);
}
}
class ChatMessageAttachment {
const ChatMessageAttachment({
required this.id,
required this.fileName,
required this.filePath,
required this.fileType,
required this.fileSize,
this.thumbnailPath,
this.width,
this.height,
this.durationMs,
this.cloudUrl,
});
final String id;
final String fileName;
final String filePath;
final String fileType;
final int fileSize;
final String? thumbnailPath;
final int? width;
final int? height;
final int? durationMs;
final String? cloudUrl;
bool get isImage => fileType.startsWith('image/');
bool get isVideo => fileType.startsWith('video/');
bool get isAudio => fileType.startsWith('audio/');
String get fileIcon {
if (isImage) return '🖼️';
if (isVideo) return '🎬';
if (isAudio) return '🎵';
if (fileType.contains('pdf')) return '📕';
if (fileType.contains('word') || fileType.contains('doc')) return '📘';
if (fileType.contains('excel') || fileType.contains('sheet')) return '📗';
if (fileType.contains('zip') || fileType.contains('rar')) return '📦';
return '📄';
}
String get displaySize {
if (fileSize < 1024) return '$fileSize B';
if (fileSize < 1024 * 1024)
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
}
Map<String, dynamic> toJson() => {
'id': id,
'fileName': fileName,
'filePath': filePath,
'fileType': fileType,
'fileSize': fileSize,
'thumbnailPath': thumbnailPath,
'width': width,
'height': height,
'durationMs': durationMs,
'cloudUrl': cloudUrl,
};
factory ChatMessageAttachment.fromJson(Map<String, dynamic> json) {
return ChatMessageAttachment(
id: json['id'] as String? ?? '',
fileName: json['fileName'] as String? ?? '',
filePath: json['filePath'] as String? ?? '',
fileType: json['fileType'] as String? ?? '',
fileSize: json['fileSize'] as int? ?? 0,
thumbnailPath: json['thumbnailPath'] as String?,
width: json['width'] as int?,
height: json['height'] as int?,
durationMs: json['durationMs'] as int?,
cloudUrl: json['cloudUrl'] as String?,
);
}
factory ChatMessageAttachment.fromDrift(ChatAttachment att) {
return ChatMessageAttachment(
id: att.id,
fileName: att.fileName,
filePath: att.filePath,
fileType: att.fileType,
fileSize: att.fileSize,
thumbnailPath: att.thumbnailPath,
width: att.width,
height: att.height,
durationMs: att.durationMs,
cloudUrl: att.cloudUrl,
);
}
}
class ChatMessage {
const ChatMessage({
required this.id,
required this.type,
required this.role,
required this.text,
this.conversationId,
this.author,
this.source,
this.category,
required this.timestamp,
this.isRead = false,
this.readCount = 0,
this.meta,
this.ext,
this.attachments = const [],
this.replyToId,
this.richContent,
this.ipText,
this.ipDetailJson,
});
final String id;
final String? conversationId;
final ChatMessageType type;
final ChatMessageRole role;
final String text;
final String? author;
final String? source;
final String? category;
final DateTime timestamp;
final bool isRead;
final int readCount;
final Map<String, dynamic>? meta;
final Map<String, dynamic>? ext;
final List<ChatMessageAttachment> attachments;
final String? replyToId;
final String? richContent;
final String? ipText;
final String? ipDetailJson;
bool get isUser => role == ChatMessageRole.user;
bool get isPush =>
type != ChatMessageType.sentence && type != ChatMessageType.userMessage;
bool get hasAttachment => attachments.isNotEmpty;
bool get isImage => type == ChatMessageType.image;
bool get isFile => type == ChatMessageType.file;
bool get isAudio => type == ChatMessageType.audio;
bool get isVideo => type == ChatMessageType.video;
bool get isRichText =>
type == ChatMessageType.richText ||
(richContent != null && richContent!.isNotEmpty);
bool get isLink => type == ChatMessageType.link;
bool get isDocument => type == ChatMessageType.document;
bool get isReadlaterSentence => type == ChatMessageType.readlaterSentence;
bool get hasReplyTo => replyToId != null && replyToId!.isNotEmpty;
bool get hasIpInfo => ipText != null && ipText!.isNotEmpty;
// ---- 标签便捷方法 (ext['tags']) ----
/// 获取消息标签列表
List<String> get getTags {
final tags = ext?['tags'];
if (tags is List) {
return tags.map((e) => e.toString()).toList();
}
return [];
}
/// 判断消息是否包含指定标签
bool hasTag(String tag) => getTags.contains(tag);
/// 添加标签返回新的ChatMessage实例
ChatMessage addTag(String tag) {
final currentTags = getTags;
if (currentTags.contains(tag)) return this;
final newTags = [...currentTags, tag];
final newExt = Map<String, dynamic>.from(ext ?? {});
newExt['tags'] = newTags;
return copyWith(ext: newExt);
}
/// 移除标签返回新的ChatMessage实例
ChatMessage removeTag(String tag) {
final currentTags = getTags;
if (!currentTags.contains(tag)) return this;
final newTags = currentTags.where((t) => t != tag).toList();
final newExt = Map<String, dynamic>.from(ext ?? {});
newExt['tags'] = newTags;
return copyWith(ext: newExt);
}
String get displayTime {
final now = DateTime.now();
final diff = now.difference(timestamp);
if (diff.inMinutes < 1) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前';
if (diff.inHours < 24) return '${diff.inHours}小时前';
if (diff.inDays < 7) return '${diff.inDays}天前';
return '${timestamp.month}/${timestamp.day}';
}
String get displayTimestamp {
final hour = timestamp.hour;
final minute = timestamp.minute.toString().padLeft(2, '0');
final period = hour < 6
? '凌晨'
: hour < 12
? '上午'
: hour < 14
? '中午'
: hour < 18
? '下午'
: '晚上';
return '$period ${hour > 12 ? hour - 12 : hour}:$minute';
}
Map<String, dynamic> toJson() => {
'id': id,
'conversationId': conversationId,
'type': type.id,
'role': role.id,
'text': text,
'author': author,
'source': source,
'category': category,
'timestamp': timestamp.toIso8601String(),
'isRead': isRead,
'readCount': readCount,
'meta': meta,
'ext': ext,
'attachments': attachments.map((a) => a.toJson()).toList(),
'replyToId': replyToId,
'richContent': richContent,
'ipText': ipText,
'ipDetailJson': ipDetailJson,
};
factory ChatMessage.fromJson(Map<String, dynamic> json) {
return ChatMessage(
id: json['id'] as String? ?? '',
conversationId: json['conversationId'] as String?,
type: ChatMessageType.fromId(json['type'] as String? ?? 'sentence'),
role: ChatMessageRole.fromId(json['role'] as String? ?? 'assistant'),
text: json['text'] as String? ?? '',
author: json['author'] as String?,
source: json['source'] as String?,
category: json['category'] as String?,
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: DateTime.now(),
isRead: json['isRead'] as bool? ?? false,
readCount: (json['readCount'] as num?)?.toInt() ?? 0,
meta: json['meta'] as Map<String, dynamic>?,
ext: json['ext'] as Map<String, dynamic>?,
attachments:
(json['attachments'] as List<dynamic>?)
?.map(
(a) =>
ChatMessageAttachment.fromJson(a as Map<String, dynamic>),
)
.toList() ??
[],
replyToId: json['replyToId'] as String?,
richContent: json['richContent'] as String?,
ipText: json['ipText'] as String?,
ipDetailJson: json['ipDetailJson'] as String?,
);
}
factory ChatMessage.fromDrift(
ChatMsgRecord record, {
List<ChatAttachment>? attachments,
}) {
Map<String, dynamic> meta = {};
try {
if (record.metaJson.isNotEmpty) {
meta = jsonDecode(record.metaJson) as Map<String, dynamic>;
}
} catch (_) {}
Map<String, dynamic> ext = {};
try {
if (record.extJson.isNotEmpty) {
ext = jsonDecode(record.extJson) as Map<String, dynamic>;
}
} catch (_) {}
return ChatMessage(
id: record.id,
conversationId: record.conversationId,
type: ChatMessageType.fromId(record.type),
role: ChatMessageRole.fromId(record.role),
text: record.content,
author: record.author,
source: record.source,
category: record.category,
timestamp: record.timestamp,
isRead: record.isRead,
readCount: record.readCount,
meta: meta.isEmpty ? null : meta,
ext: ext.isEmpty ? null : ext,
attachments:
attachments
?.map((a) => ChatMessageAttachment.fromDrift(a))
.toList() ??
[],
replyToId: record.replyToId,
richContent: record.richContent.isEmpty ? null : record.richContent,
ipText: record.ipText.isEmpty ? null : record.ipText,
ipDetailJson: record.ipDetailJson.isEmpty ? null : record.ipDetailJson,
);
}
ChatMessage copyWith({
String? id,
String? conversationId,
ChatMessageType? type,
ChatMessageRole? role,
String? text,
String? author,
String? source,
String? category,
DateTime? timestamp,
bool? isRead,
int? readCount,
Map<String, dynamic>? meta,
Map<String, dynamic>? ext,
List<ChatMessageAttachment>? attachments,
String? replyToId,
String? richContent,
String? ipText,
String? ipDetailJson,
}) {
return ChatMessage(
id: id ?? this.id,
conversationId: conversationId ?? this.conversationId,
type: type ?? this.type,
role: role ?? this.role,
text: text ?? this.text,
author: author ?? this.author,
source: source ?? this.source,
category: category ?? this.category,
timestamp: timestamp ?? this.timestamp,
isRead: isRead ?? this.isRead,
readCount: readCount ?? this.readCount,
meta: meta ?? this.meta,
ext: ext ?? this.ext,
attachments: attachments ?? this.attachments,
replyToId: replyToId ?? this.replyToId,
richContent: richContent ?? this.richContent,
ipText: ipText ?? this.ipText,
ipDetailJson: ipDetailJson ?? this.ipDetailJson,
);
}
static String exportToJson(List<ChatMessage> messages) {
final list = messages.map((m) => m.toJson()).toList();
return const JsonEncoder.withIndent(' ').convert({
'version': 2,
'exportTime': DateTime.now().toIso8601String(),
'count': list.length,
'messages': list,
});
}
static List<ChatMessage> importFromJson(String jsonStr) {
try {
final map = jsonDecode(jsonStr) as Map<String, dynamic>;
final list = map['messages'] as List<dynamic>? ?? [];
return list
.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
return [];
}
}
}