main
dart 482 lines 15.1 KB
Raw
1 import 'dart:async';
2 import 'dart:convert';
3
4 import '../../../core/logger/app_logger.dart';
5 import '../../../core/models/enums.dart';
6 import '../../servers/data/models/server.dart';
7 import 'chat_service.dart';
8 import 'models/chat_parameters.dart';
9 import 'models/message.dart';
10
11 class SmartReplyService {
12 static final RegExp _greetingPattern = RegExp(
13 r'\b(?:hello|hi|hey|greetings|good morning|good afternoon|good evening|welcome)\b',
14 caseSensitive: false,
15 );
16
17 final Map<String, Future<List<String>>> _inFlightSuggestions = {};
18
19 Future<List<String>> suggestRepliesWithLLM({
20 required ChatService chatService,
21 required Server server,
22 required String modelId,
23 required List<Message> messages,
24 required ChatParameters params,
25 String? personaSystemPrompt,
26 }) async {
27 if (messages.isEmpty) return [];
28
29 final lastMessage = messages.last;
30 final requestKey =
31 '${lastMessage.conversationId}:${lastMessage.id}:$modelId';
32 final existing = _inFlightSuggestions[requestKey];
33 if (existing != null) return existing;
34
35 final request = _suggestRepliesWithLLM(
36 chatService: chatService,
37 server: server,
38 modelId: modelId,
39 messages: messages,
40 params: params,
41 personaSystemPrompt: personaSystemPrompt,
42 );
43 _inFlightSuggestions[requestKey] = request;
44 try {
45 return await request;
46 } finally {
47 if (identical(_inFlightSuggestions[requestKey], request)) {
48 _inFlightSuggestions.remove(requestKey);
49 }
50 }
51 }
52
53 Future<List<String>> _suggestRepliesWithLLM({
54 required ChatService chatService,
55 required Server server,
56 required String modelId,
57 required List<Message> messages,
58 required ChatParameters params,
59 String? personaSystemPrompt,
60 }) async {
61 if (messages.isEmpty) return [];
62
63 final lastMessage = messages.last;
64
65 // Construct the prompt message for generating suggestions
66 final promptMessage = Message(
67 id: 'smart-reply-prompt',
68 conversationId: lastMessage.conversationId,
69 role: MessageRole.user,
70 content:
71 'Based on the conversation history above, suggest 3 short, natural, context-appropriate reply options that the user (human) might want to send next. '
72 'Return them strictly as a JSON array of strings, for example: ["Reply 1", "Reply 2", "Reply 3"]. '
73 'Each reply should be brief (under 8 words). Return ONLY the raw JSON array, with no markdown code block formatting (do not wrap in ```json), explanations, or extra text.',
74 createdAt: DateTime.now(),
75 );
76
77 final apiMessages = [...messages, promptMessage];
78
79 const baseInstruction =
80 'You are a smart reply assistant. Your job is to output exactly a JSON array containing 3 suggested short replies for the user. Do not output anything other than the JSON array.';
81
82 // Use low temperature and small tokens for fast, cheap, and deterministic suggestions
83 final suggestionParams = params.copyWith(
84 temperature: 0.2,
85 maxTokens: 128,
86 systemPrompt:
87 personaSystemPrompt != null && personaSystemPrompt.trim().isNotEmpty
88 ? '$personaSystemPrompt\n\n$baseInstruction'
89 : baseInstruction,
90 );
91
92 try {
93 final completer = Completer<String>();
94 String accumulatedContent = '';
95
96 final stream = chatService.sendMessage(
97 server: server,
98 modelId: modelId,
99 messages: apiMessages,
100 params: suggestionParams,
101 );
102
103 StreamSubscription? subscription;
104 subscription = stream.listen(
105 (response) {
106 if (response.type == ChatResponseType.message &&
107 response.content != null) {
108 accumulatedContent += response.content!;
109 } else if (response.type == ChatResponseType.done) {
110 subscription?.cancel();
111 if (!completer.isCompleted) {
112 completer.complete(accumulatedContent);
113 }
114 } else if (response.type == ChatResponseType.error) {
115 subscription?.cancel();
116 if (!completer.isCompleted) {
117 completer.completeError(
118 response.content ?? 'Unknown streaming error',
119 );
120 }
121 }
122 },
123 onError: (err) {
124 subscription?.cancel();
125 if (!completer.isCompleted) {
126 completer.completeError(err);
127 }
128 },
129 onDone: () {
130 subscription?.cancel();
131 if (!completer.isCompleted) {
132 completer.complete(accumulatedContent);
133 }
134 },
135 cancelOnError: true,
136 );
137
138 final rawResponse = await completer.future.timeout(
139 const Duration(seconds: 15),
140 onTimeout: () {
141 subscription?.cancel();
142 return accumulatedContent;
143 },
144 );
145
146 return _parseSuggestions(rawResponse);
147 } catch (e) {
148 Log.warning('LLM SmartReply generation failed: $e');
149 return [];
150 }
151 }
152
153 Future<String?> generateUserMessage({
154 required ChatService chatService,
155 required Server server,
156 required String modelId,
157 required List<Message> messages,
158 required ChatParameters params,
159 }) async {
160 if (messages.isEmpty) return null;
161
162 return _generateUserMessageViaChat(
163 chatService: chatService,
164 server: server,
165 modelId: modelId,
166 messages: messages,
167 params: params,
168 );
169 }
170
171 Future<String?> _generateUserMessageViaChat({
172 required ChatService chatService,
173 required Server server,
174 required String modelId,
175 required List<Message> messages,
176 required ChatParameters params,
177 }) async {
178 final promptMessage = Message(
179 id: 'ai-user-response-prompt',
180 conversationId: messages.last.conversationId,
181 role: MessageRole.user,
182 content:
183 'Based on the conversation above, write the next user message that a human would naturally send. '
184 'Return ONLY the raw message text with no quotes, labels, markdown, or explanation.',
185 createdAt: DateTime.now(),
186 );
187
188 final suggestionParams = params.copyWith(
189 temperature: 0.7,
190 maxTokens: 256,
191 systemPrompt:
192 'You write realistic user messages for chat conversations. Output only the user message text.',
193 );
194
195 try {
196 final completer = Completer<String>();
197 var accumulatedContent = '';
198
199 final stream = chatService.sendMessage(
200 server: server,
201 modelId: modelId,
202 messages: [...messages, promptMessage],
203 params: suggestionParams,
204 );
205
206 StreamSubscription? subscription;
207 subscription = stream.listen(
208 (response) {
209 if (response.type == ChatResponseType.message &&
210 response.content != null) {
211 accumulatedContent += response.content!;
212 } else if (response.type == ChatResponseType.done) {
213 subscription?.cancel();
214 if (!completer.isCompleted) {
215 completer.complete(accumulatedContent);
216 }
217 } else if (response.type == ChatResponseType.error) {
218 subscription?.cancel();
219 if (!completer.isCompleted) {
220 completer.completeError(response.content ?? 'Unknown error');
221 }
222 }
223 },
224 onError: (err) {
225 subscription?.cancel();
226 if (!completer.isCompleted) completer.completeError(err);
227 },
228 onDone: () {
229 subscription?.cancel();
230 if (!completer.isCompleted) completer.complete(accumulatedContent);
231 },
232 cancelOnError: true,
233 );
234
235 final rawResponse = await completer.future.timeout(
236 const Duration(seconds: 20),
237 onTimeout: () {
238 subscription?.cancel();
239 return accumulatedContent;
240 },
241 );
242
243 return _cleanGeneratedUserMessage(rawResponse);
244 } catch (e) {
245 Log.warning('AI user message generation failed: $e');
246 return null;
247 }
248 }
249
250 String? _cleanGeneratedUserMessage(String? rawResponse) {
251 if (rawResponse == null) return null;
252 final cleaned = rawResponse.trim();
253 if (cleaned.isEmpty) return null;
254 if (cleaned.startsWith('"') && cleaned.endsWith('"')) {
255 return cleaned.substring(1, cleaned.length - 1).trim();
256 }
257 return cleaned;
258 }
259
260 List<String> _parseSuggestions(String text) {
261 final cleaned = text.trim();
262
263 // 1. Try parsing as JSON array
264 try {
265 final startIdx = cleaned.indexOf('[');
266 final endIdx = cleaned.lastIndexOf(']');
267 if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) {
268 final jsonStr = cleaned.substring(startIdx, endIdx + 1);
269 final decoded = jsonDecode(jsonStr);
270 if (decoded is List) {
271 return decoded
272 .map((e) => e.toString().trim())
273 .where((s) => s.isNotEmpty)
274 .toList();
275 }
276 }
277 } catch (_) {
278 // Ignore JSON parsing failures and fall back to line-by-line parsing
279 }
280
281 // 2. Fallback: Line-based parsing (numbered list, bullet points, etc.)
282 final suggestions = <String>[];
283 final lines = cleaned.split('\n');
284 for (var line in lines) {
285 line = line.trim();
286 if (line.isEmpty) continue;
287
288 // Remove numbered list prefixes like "1. ", "2)", etc.
289 // Also remove bullet points like "- ", "* "
290 line = line.replaceFirst(RegExp(r'^(\d+\.\s*|\d+\)\s*|[-*+]\s*)'), '');
291 line = line.trim();
292
293 // Strip surrounding quotes if present
294 if (line.startsWith('"') && line.endsWith('"')) {
295 line = line.substring(1, line.length - 1).trim();
296 } else if (line.startsWith("'") && line.endsWith("'")) {
297 line = line.substring(1, line.length - 1).trim();
298 }
299
300 if (line.isNotEmpty && line.length < 100) {
301 suggestions.add(line);
302 }
303 }
304 return suggestions.take(3).toList();
305 }
306
307 List<String> getFallbackReplies(String content) {
308 final cleanContent = content.trim();
309 final lowerContent = cleanContent.toLowerCase();
310
311 // 1. Greetings & Welcomes
312 if (_greetingPattern.hasMatch(cleanContent)) {
313 return [
314 'Hello! How are you?',
315 'I have a question',
316 'Let\'s get started!',
317 ];
318 }
319
320 // 2. Gratitude & Farewells
321 if (lowerContent.contains('thank you') ||
322 lowerContent.contains('thanks') ||
323 lowerContent.contains('great help') ||
324 lowerContent.contains('solved') ||
325 lowerContent.contains('goodbye') ||
326 lowerContent.contains('bye') ||
327 lowerContent.contains('see you')) {
328 return ['You\'re welcome!', 'Thanks for your help!', 'Have a great day!'];
329 }
330
331 // 3. Code & Programming
332 if (lowerContent.contains('```') ||
333 lowerContent.contains('function') ||
334 lowerContent.contains('class ') ||
335 lowerContent.contains('import ') ||
336 lowerContent.contains('const ') ||
337 lowerContent.contains('def ') ||
338 lowerContent.contains('fn ') ||
339 lowerContent.contains('public static') ||
340 lowerContent.contains('std::') ||
341 lowerContent.contains('package:')) {
342 return [
343 'Explain this code',
344 'How can I improve this?',
345 'Add error handling',
346 'Write tests for this',
347 ];
348 }
349
350 // 4. Debugging & Errors
351 if (lowerContent.contains('error') ||
352 lowerContent.contains('exception') ||
353 lowerContent.contains('failed') ||
354 lowerContent.contains('crash') ||
355 lowerContent.contains('bug') ||
356 lowerContent.contains('issue') ||
357 lowerContent.contains('problem') ||
358 lowerContent.contains('broken') ||
359 lowerContent.contains('debug') ||
360 lowerContent.contains('stacktrace')) {
361 return [
362 'How do I fix this error?',
363 'What causes this issue?',
364 'Show me a fix',
365 'How to prevent this?',
366 ];
367 }
368
369 // 5. Lists & Steps
370 final hasNumberedList = RegExp(r'\b\d+\.\s').hasMatch(cleanContent);
371 final hasBulletList = RegExp(
372 r'^[\s]*[-*+]\s',
373 multiLine: true,
374 ).hasMatch(cleanContent);
375 if (hasNumberedList ||
376 hasBulletList ||
377 lowerContent.contains('step 1') ||
378 lowerContent.contains('firstly') ||
379 lowerContent.contains('secondly') ||
380 lowerContent.contains('finally') ||
381 lowerContent.contains('steps:')) {
382 return [
383 'Can you elaborate on step 1?',
384 'What is the next step?',
385 'Give me a summary',
386 'Can you simplify this?',
387 ];
388 }
389
390 // 6. Questions asked by the assistant
391 if (cleanContent.endsWith('?')) {
392 final isYesNoQuestion =
393 lowerContent.contains('do you') ||
394 lowerContent.contains('can you') ||
395 lowerContent.contains('is it') ||
396 lowerContent.contains('are you') ||
397 lowerContent.contains('would you') ||
398 lowerContent.contains('should we');
399 if (isYesNoQuestion) {
400 return ['Yes, please.', 'No, thank you.', 'Can you explain more?'];
401 }
402 return [
403 'Sure, tell me more.',
404 'I\'m not sure, what do you think?',
405 'Can you give me an example?',
406 ];
407 }
408
409 // 7. Writing & Content Creation
410 if (lowerContent.contains('write') ||
411 lowerContent.contains('essay') ||
412 lowerContent.contains('poem') ||
413 lowerContent.contains('story') ||
414 lowerContent.contains('summarize') ||
415 lowerContent.contains('draft') ||
416 lowerContent.contains('paragraph') ||
417 lowerContent.contains('grammar') ||
418 lowerContent.contains('article') ||
419 lowerContent.contains('translation') ||
420 lowerContent.contains('translate')) {
421 return [
422 'Can you make it shorter?',
423 'Make it more professional',
424 'Can you change the tone?',
425 'Check for grammatical errors',
426 ];
427 }
428
429 // 8. Data & Tables
430 if (lowerContent.contains('data') ||
431 lowerContent.contains('json') ||
432 lowerContent.contains('csv') ||
433 lowerContent.contains('table') ||
434 lowerContent.contains('chart') ||
435 lowerContent.contains('database') ||
436 lowerContent.contains('sql') ||
437 lowerContent.contains('format') ||
438 lowerContent.contains('query') ||
439 lowerContent.contains('analytics')) {
440 return [
441 'Format this as a markdown table',
442 'Convert this to JSON',
443 'How do I query this?',
444 'Summarize key insights',
445 ];
446 }
447
448 // 9. Planning & Ideas
449 if (lowerContent.contains('plan') ||
450 lowerContent.contains('idea') ||
451 lowerContent.contains('brainstorm') ||
452 lowerContent.contains('suggest') ||
453 lowerContent.contains('recommend') ||
454 lowerContent.contains('options') ||
455 lowerContent.contains('alternatives') ||
456 lowerContent.contains('strategies') ||
457 lowerContent.contains('tips')) {
458 return [
459 'Give me more options',
460 'Which one do you recommend?',
461 'What are the pros and cons?',
462 'Help me choose between them',
463 ];
464 }
465
466 // 10. Default General suggestions
467 return [
468 'Tell me more',
469 'Give me an example',
470 'Can you explain this simpler?',
471 'What are the alternatives?',
472 ];
473 }
474
475 void reset() {
476 _inFlightSuggestions.clear();
477 }
478
479 void dispose() {
480 _inFlightSuggestions.clear();
481 }
482 }