feat: implement mobile AI chat feature and fix duplicate response issue (#610)

Backend fixes:
- Fix duplicate AssistantResponseJob triggering causing duplicate AI responses
- UserMessage model already handles job triggering via after_create_commit callback
- Remove redundant job enqueue in chats_controller and messages_controller

Mobile app features:
- Implement complete AI chat interface and conversation management
- Add Chat, Message, and ToolCall data models
- Add ChatProvider for state management with polling mechanism
- Add ChatService to handle all chat-related API requests
- Add chat list screen (ChatListScreen)
- Add conversation detail screen (ChatConversationScreen)
- Refactor navigation structure with bottom navigation bar (MainNavigationScreen)
- Add settings screen (SettingsScreen)
- Optimize TransactionsProvider to support account filtering

Technical details:
- Implement message polling mechanism for real-time AI responses
- Support chat creation, deletion, retry and other operations
- Integrate Material Design 3 design language
- Improve user experience and error handling

Co-authored-by: dwvwdv <dwvwdv@protonmail.com>
This commit is contained in:
Lazy Bone
2026-01-11 19:45:33 +08:00
committed by GitHub
parent 38f4c2222c
commit f52b3fceb6
13 changed files with 1869 additions and 8 deletions

View File

@@ -0,0 +1,77 @@
import 'message.dart';
class Chat {
final String id;
final String title;
final String? error;
final DateTime createdAt;
final DateTime updatedAt;
final List<Message> messages;
final int? messageCount;
final DateTime? lastMessageAt;
Chat({
required this.id,
required this.title,
this.error,
required this.createdAt,
required this.updatedAt,
this.messages = const [],
this.messageCount,
this.lastMessageAt,
});
factory Chat.fromJson(Map<String, dynamic> json) {
return Chat(
id: json['id'].toString(),
title: json['title'] as String,
error: json['error'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
messages: json['messages'] != null
? (json['messages'] as List)
.map((m) => Message.fromJson(m as Map<String, dynamic>))
.toList()
: [],
messageCount: json['message_count'] as int?,
lastMessageAt: json['last_message_at'] != null
? DateTime.parse(json['last_message_at'] as String)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'error': error,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'messages': messages.map((m) => m.toJson()).toList(),
'message_count': messageCount,
'last_message_at': lastMessageAt?.toIso8601String(),
};
}
Chat copyWith({
String? id,
String? title,
String? error,
DateTime? createdAt,
DateTime? updatedAt,
List<Message>? messages,
int? messageCount,
DateTime? lastMessageAt,
}) {
return Chat(
id: id ?? this.id,
title: title ?? this.title,
error: error ?? this.error,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
messages: messages ?? this.messages,
messageCount: messageCount ?? this.messageCount,
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
);
}
}

View File

@@ -0,0 +1,56 @@
import 'tool_call.dart';
class Message {
final String id;
final String type;
final String role;
final String content;
final String? model;
final DateTime createdAt;
final DateTime updatedAt;
final List<ToolCall>? toolCalls;
Message({
required this.id,
required this.type,
required this.role,
required this.content,
this.model,
required this.createdAt,
required this.updatedAt,
this.toolCalls,
});
factory Message.fromJson(Map<String, dynamic> json) {
return Message(
id: json['id'].toString(),
type: json['type'] as String,
role: json['role'] as String,
content: json['content'] as String,
model: json['model'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
toolCalls: json['tool_calls'] != null
? (json['tool_calls'] as List)
.map((tc) => ToolCall.fromJson(tc as Map<String, dynamic>))
.toList()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'role': role,
'content': content,
'model': model,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'tool_calls': toolCalls?.map((tc) => tc.toJson()).toList(),
};
}
bool get isUser => role == 'user';
bool get isAssistant => role == 'assistant';
}

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
class ToolCall {
final String id;
final String functionName;
final Map<String, dynamic> functionArguments;
final Map<String, dynamic>? functionResult;
final DateTime createdAt;
ToolCall({
required this.id,
required this.functionName,
required this.functionArguments,
this.functionResult,
required this.createdAt,
});
factory ToolCall.fromJson(Map<String, dynamic> json) {
return ToolCall(
id: json['id'].toString(),
functionName: json['function_name'] as String,
functionArguments: _parseJsonField(json['function_arguments']),
functionResult: json['function_result'] != null
? _parseJsonField(json['function_result'])
: null,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
static Map<String, dynamic> _parseJsonField(dynamic field) {
if (field == null) return {};
if (field is Map<String, dynamic>) return field;
if (field is String) {
try {
final parsed = jsonDecode(field);
return parsed is Map<String, dynamic> ? parsed : {};
} catch (e) {
return {};
}
}
return {};
}
Map<String, dynamic> toJson() {
return {
'id': id,
'function_name': functionName,
'function_arguments': functionArguments,
'function_result': functionResult,
'created_at': createdAt.toIso8601String(),
};
}
}