| 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 | } |