主要变更: 1. 全局修复类型转换问题,将多处`as int?`改为`(num?)?.toInt()`兼容浮点/字符串类型的数字字段 2. 移除废弃的nearby_p2p配对方式和对应的依赖包 3. 优化鸿蒙端快捷方式、引导页、路由导航的稳定性 4. 合并日志输出避免鸿蒙端IDE卡顿 5. 修复安卓端蓝牙权限冗余声明
425 lines
13 KiB
Dart
425 lines
13 KiB
Dart
// ============================================================
|
||
// 闲言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 [];
|
||
}
|
||
}
|
||
}
|