Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions app/lib/backend/http/api/conversation_chat.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:omi/backend/http/shared.dart';
import 'package:omi/backend/schema/message.dart';
import 'package:omi/env/env.dart';
import 'package:omi/utils/other/string_utils.dart';

// Models for conversation chat
class ConversationChatMessage {
final String id;
final String text;
final DateTime createdAt;
final String sender; // 'human' or 'ai'
final String conversationId;
final List<String> memoriesId;
final List<String> actionItemsId;
final bool reported;

ConversationChatMessage({
required this.id,
required this.text,
required this.createdAt,
required this.sender,
required this.conversationId,
this.memoriesId = const [],
this.actionItemsId = const [],
this.reported = false,
});

factory ConversationChatMessage.fromJson(Map<String, dynamic> json) {
return ConversationChatMessage(
id: json['id'],
text: json['text'],
createdAt: DateTime.parse(json['created_at']),
sender: json['sender'],
conversationId: json['conversation_id'],
memoriesId: List<String>.from(json['memories_id'] ?? []),
actionItemsId: List<String>.from(json['action_items_id'] ?? []),
reported: json['reported'] ?? false,
);
}

bool get isFromUser => sender == 'human';
bool get isFromAI => sender == 'ai';
}

class ConversationChatResponse {
final ConversationChatMessage message;
final bool askForNps;

ConversationChatResponse({
required this.message,
required this.askForNps,
});

factory ConversationChatResponse.fromJson(Map<String, dynamic> json) {
return ConversationChatResponse(
message: ConversationChatMessage.fromJson(json),
askForNps: json['ask_for_nps'] ?? false,
);
}
}

// API Functions
Future<List<ConversationChatMessage>> getConversationMessages(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v2/conversations/$conversationId/chat/messages',
headers: {},
method: 'GET',
body: '',
);

if (response == null) return [];
if (response.statusCode == 200) {
var body = utf8.decode(response.bodyBytes);
var decodedBody = jsonDecode(body) as List<dynamic>;
if (decodedBody.isEmpty) {
return [];
}
var messages = decodedBody.map((messageJson) => ConversationChatMessage.fromJson(messageJson)).toList();
debugPrint('getConversationMessages length: ${messages.length}');
return messages;
}
debugPrint('getConversationMessages error ${response.statusCode}');
return [];
}

Future<bool> clearConversationChat(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v2/conversations/$conversationId/chat/messages',
headers: {},
method: 'DELETE',
body: '',
);

if (response == null) {
return false;
}

return response.statusCode == 200;
}

// Parse conversation chat streaming chunks (similar to main chat)
ServerMessageChunk? parseConversationChatChunk(String line, String messageId) {
if (line.startsWith('think: ')) {
return ServerMessageChunk(messageId, line.substring(7).replaceAll("__CRLF__", "\n"), MessageChunkType.think);
}

if (line.startsWith('data: ')) {
return ServerMessageChunk(messageId, line.substring(6).replaceAll("__CRLF__", "\n"), MessageChunkType.data);
}

if (line.startsWith('done: ')) {
var text = decodeBase64(line.substring(6));
var responseJson = json.decode(text);
return ServerMessageChunk(
messageId,
text,
MessageChunkType.done,
message: ServerMessage(
responseJson['id'],
DateTime.parse(responseJson['created_at']).toLocal(),
responseJson['text'],
MessageSender.values.firstWhere((e) => e.toString().split('.').last == responseJson['sender']),
MessageType.text,
null, // appId
false, // fromIntegration
[], // files
[], // filesId
[], // memories
askForNps: responseJson['ask_for_nps'] ?? false,
),
);
}

return null;
}

Stream<ServerMessageChunk> sendConversationMessageStream(String conversationId, String text) async* {
var url = '${Env.apiBaseUrl}v2/conversations/$conversationId/chat/messages';
var messageId = "conv_chat_${DateTime.now().millisecondsSinceEpoch}";

await for (var line in makeStreamingApiCall(
url: url,
body: jsonEncode({
'text': text,
'conversation_id': conversationId,
}),
)) {
var messageChunk = parseConversationChatChunk(line, messageId);
if (messageChunk != null) {
yield messageChunk;
} else {
yield ServerMessageChunk.failedMessage();
return;
}
}
}

Future<Map<String, dynamic>?> getConversationContext(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v2/conversations/$conversationId/chat/context',
headers: {},
method: 'GET',
body: '',
);

if (response == null) return null;
if (response.statusCode == 200) {
return jsonDecode(utf8.decode(response.bodyBytes));
}
debugPrint('getConversationContext error ${response.statusCode}');
return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ class ConversationDetailProvider extends ChangeNotifier with MessageNotifierMixi
final scaffoldKey = GlobalKey<ScaffoldState>();
List<App> get appsList => appProvider?.apps ?? [];

// Callback for clearing chat messages in UI
VoidCallback? _clearChatMessagesCallback;

void registerClearChatCallback(VoidCallback callback) {
_clearChatMessagesCallback = callback;
}

void clearChatMessages() {
if (_clearChatMessagesCallback != null) {
_clearChatMessagesCallback!();
}
}

Structured get structured {
return conversation.structured;
}
Expand Down
52 changes: 37 additions & 15 deletions app/lib/pages/conversation_detail/page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import 'package:pull_down_button/pull_down_button.dart';

import 'conversation_detail_provider.dart';
import 'widgets/name_speaker_sheet.dart';
import 'widgets/chat_tab.dart';
import 'share.dart';
import 'test_prompts.dart';
import 'package:omi/pages/settings/developer.dart';
Expand Down Expand Up @@ -128,8 +129,11 @@ class _ConversationDetailPageState extends State<ConversationDetailPage> with Ti
void initState() {
super.initState();

_controller = TabController(length: 3, vsync: this, initialIndex: 1); // Start with summary tab
_controller = TabController(length: 4, vsync: this, initialIndex: 1); // Start with summary tab
_controller!.addListener(() {
// Dismiss keyboard when switching tabs for clean UX
FocusScope.of(context).unfocus();

setState(() {
switch (_controller!.index) {
case 0:
Expand All @@ -141,6 +145,9 @@ class _ConversationDetailPageState extends State<ConversationDetailPage> with Ti
case 2:
selectedTab = ConversationTab.actionItems;
break;
case 3:
selectedTab = ConversationTab.chat;
break;
default:
debugPrint('Invalid tab index: ${_controller!.index}');
selectedTab = ConversationTab.summary;
Expand Down Expand Up @@ -205,6 +212,8 @@ class _ConversationDetailPageState extends State<ConversationDetailPage> with Ti
return 'Conversation';
case ConversationTab.actionItems:
return 'Action Items';
case ConversationTab.chat:
return 'Chat';
}
}

Expand Down Expand Up @@ -300,6 +309,7 @@ class _ConversationDetailPageState extends State<ConversationDetailPage> with Ti
child: Scaffold(
key: scaffoldKey,
extendBody: true,
resizeToAvoidBottomInset: selectedTab != ConversationTab.chat, // Don't resize on chat tab
backgroundColor: Theme.of(context).colorScheme.primary,
appBar: AppBar(
automaticallyImplyLeading: false,
Expand Down Expand Up @@ -578,14 +588,15 @@ class _ConversationDetailPageState extends State<ConversationDetailPage> with Ti
child: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Builder(builder: (context) {
return TabBarView(
controller: _controller,
physics: const NeverScrollableScrollPhysics(),
children: [
TranscriptWidgets(
child: Builder(builder: (context) {
return TabBarView(
controller: _controller,
physics: const NeverScrollableScrollPhysics(),
children: [
// Other tabs with padding
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TranscriptWidgets(
searchQuery: _searchQuery,
currentResultIndex: getCurrentResultIndexForHighlighting(),
onTapWhenSearchEmpty: () {
Expand All @@ -598,7 +609,10 @@ class _ConversationDetailPageState extends State<ConversationDetailPage> with Ti
}
},
),
SummaryTab(
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SummaryTab(
searchQuery: _searchQuery,
currentResultIndex: getCurrentResultIndexForHighlighting(),
onTapWhenSearchEmpty: () {
Expand All @@ -611,11 +625,16 @@ class _ConversationDetailPageState extends State<ConversationDetailPage> with Ti
}
},
),
ActionItemsTab(),
],
);
}),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ActionItemsTab(),
),
// Chat tab with NO padding - 100% width
ChatTab(),
],
);
}),
),
],
),
Expand Down Expand Up @@ -647,6 +666,9 @@ class _ConversationDetailPageState extends State<ConversationDetailPage> with Ti
case ConversationTab.actionItems:
index = 2;
break;
case ConversationTab.chat:
index = 3;
break;
}
_controller!.animateTo(index);
},
Expand Down
Loading